Compare commits
50 Commits
fix-vue-no
...
command-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da195c925 | ||
|
|
d3398944d5 | ||
|
|
b70b2c89b2 | ||
|
|
c0303a6553 | ||
|
|
1e6803fd65 | ||
|
|
38a77abecb | ||
|
|
fcffa51a24 | ||
|
|
d0ef1bb81a | ||
|
|
34b7fe14c4 | ||
|
|
a4d7b4dd55 | ||
|
|
109542dca3 | ||
|
|
ffc812a8f5 | ||
|
|
b745f533ba | ||
|
|
8f289c8e67 | ||
|
|
79b4c78116 | ||
|
|
48aea928e0 | ||
|
|
03ad06ea14 | ||
|
|
ff5943f770 | ||
|
|
b1117b9838 | ||
|
|
2d11fb1f90 | ||
|
|
e70b127f2a | ||
|
|
0d8e4fe719 | ||
|
|
5f5f44b310 | ||
|
|
b42878a9da | ||
|
|
5cc269eff1 | ||
|
|
16d7436883 | ||
|
|
db452c1e63 | ||
|
|
10d80165c4 | ||
|
|
c3997dfdb0 | ||
|
|
7bbbf59722 | ||
|
|
8bf60777e7 | ||
|
|
ba28fa4621 | ||
|
|
95ab88693c | ||
|
|
5d71d6f9cf | ||
|
|
8899b425a8 | ||
|
|
1fc4fd2ca8 | ||
|
|
1b9bacaeef | ||
|
|
65cc06771c | ||
|
|
3c154d8487 | ||
|
|
c6c20e53fb | ||
|
|
70c06d10bb | ||
|
|
f4482eb35a | ||
|
|
e77411d9da | ||
|
|
04f8ae416f | ||
|
|
e63337ccbe | ||
|
|
385c56d93d | ||
|
|
f3b82e377a | ||
|
|
85aa89da45 | ||
|
|
5e9b8785a5 | ||
|
|
386eb9391a |
6
.github/workflows/claude-pr-review.yml
vendored
@@ -19,10 +19,10 @@ jobs:
|
||||
should-proceed: ${{ steps.check-status.outputs.proceed }}
|
||||
steps:
|
||||
- name: Wait for other CI checks
|
||||
uses: lewagon/wait-on-check-action@v1.3.1
|
||||
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
check-regexp: '^(eslint|prettier|test|playwright-tests)'
|
||||
check-regexp: '^(lint-and-format|test|playwright-tests)'
|
||||
wait-interval: 30
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
id: check-status
|
||||
run: |
|
||||
# Get all check runs for this commit
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format|test|playwright-tests")) | {name, conclusion}')
|
||||
|
||||
# Check if any required checks failed
|
||||
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
-d '{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": ["build", "test"]
|
||||
"contexts": ["lint-and-format", "test", "playwright-tests"]
|
||||
},
|
||||
"enforce_admins": false,
|
||||
"required_pull_request_reviews": {
|
||||
|
||||
17
.github/workflows/eslint.yaml
vendored
@@ -1,17 +0,0 @@
|
||||
name: ESLint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [ wip/*, draft/*, temp/* ]
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
23
.github/workflows/format.yaml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Prettier Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [ wip/*, draft/*, temp/* ]
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Prettier check
|
||||
run: npm run format:check
|
||||
82
.github/workflows/lint-and-format.yaml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Lint and Format
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint with auto-fix
|
||||
run: npm run lint:fix
|
||||
|
||||
- name: Run Prettier with auto-format
|
||||
run: npm run format
|
||||
|
||||
- name: Check for changes
|
||||
id: verify-changed-files
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit changes
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add .
|
||||
git commit -m "[auto-fix] Apply ESLint and Prettier fixes"
|
||||
git push
|
||||
|
||||
- name: Final validation
|
||||
run: |
|
||||
npm run lint
|
||||
npm run format:check
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
|
||||
})
|
||||
|
||||
- name: Comment on PR about manual fix needed
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\nnpm run prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\nnpm run lint:fix\nnpm run format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
|
||||
})
|
||||
4
.github/workflows/test-ui.yaml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration]
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
2
.github/workflows/update-manager-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
|
||||
1
.gitignore
vendored
@@ -41,6 +41,7 @@ tests-ui/workflows/examples
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/**/*-win32.png
|
||||
browser_tests/**/*-darwin.png
|
||||
|
||||
.env
|
||||
|
||||
|
||||
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source: `src/` (Vue 3 + TypeScript). Key areas: `components/`, `views/`, `stores/` (Pinia), `composables/`, `services/`, `utils/`, `assets/`, `locales/`.
|
||||
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
|
||||
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
|
||||
- Public assets: `public/`. Build output: `dist/`.
|
||||
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.js`, `.prettierrc`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm run dev`: Start Vite dev server.
|
||||
- `npm run dev:electron`: Dev server with Electron API mocks.
|
||||
- `npm run build`: Type-check then production build to `dist/`.
|
||||
- `npm run preview`: Preview the production build locally.
|
||||
- `npm run test:unit`: Run Vitest unit tests (`tests-ui/`).
|
||||
- `npm run test:component`: Run component tests (`src/components/`).
|
||||
- `npm run test:browser`: Run Playwright E2E tests (`browser_tests/`).
|
||||
- `npm run lint` / `npm run lint:fix`: Lint (ESLint). `npm run format` / `format:check`: Prettier.
|
||||
- `npm run typecheck`: Vue TSC type checking.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript, Vue SFCs (`.vue`). Indent 2 spaces; single quotes; no semicolons; width 80 (see `.prettierrc`).
|
||||
- Imports: sorted/grouped by plugin; run `npm run format` before committing.
|
||||
- ESLint: Vue + TS rules; no floating promises; unused imports disallowed; i18n raw text restrictions in templates.
|
||||
- Naming: Vue components in PascalCase (e.g., `MenuHamburger.vue`); composables `useXyz.ts`; Pinia stores `*Store.ts`.
|
||||
|
||||
## Testing Guidelines
|
||||
- Frameworks: Vitest (unit/component, happy-dom) and Playwright (E2E).
|
||||
- Test files: `**/*.{test,spec}.{ts,tsx,js}` under `tests-ui/`, `src/components/`, and `src/lib/litegraph/test/`.
|
||||
- Coverage: text/json/html reporters enabled; aim to cover critical logic and new features.
|
||||
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
|
||||
- PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
|
||||
- Quality gates: `npm run lint`, `npm run typecheck`, and relevant tests must pass. Keep PRs focused and small.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Secrets: Use `.env` (see `.env_example`); do not commit secrets.
|
||||
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.
|
||||
333
CONTRIBUTING.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Contributing to ComfyUI Frontend
|
||||
|
||||
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- **Pull Requests:** Add features, fix bugs, or improve code health. Browse [issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues) for inspiration. Look for the `Good first issue` label if you're new to the project.
|
||||
- **Vote on Features:** Give a 👍 to the feature requests you care about to help us prioritize.
|
||||
- **Verify Bugs:** Try reproducing reported issues and share your results (even if the bug doesn't occur!).
|
||||
- **Community Support:** Hop into our [Discord](https://discord.com/invite/comfyorg) to answer questions or get help.
|
||||
- **Share & Advocate:** Tell your friends, tweet about us, or share tips to support the project.
|
||||
|
||||
Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance
|
||||
|
||||
- **Tech Stack**:
|
||||
- [Vue 3.5 Composition API](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||
- litegraph.js (integrated in src/lib) for node editor
|
||||
- [zod](https://zod.dev/) for schema validation
|
||||
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||
cd ComfyUI_frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment (optional):
|
||||
Create a `.env` file in the project root based on the provided [.env.example](.env.example) file.
|
||||
|
||||
**Note about ports**: By default, the dev server expects the ComfyUI backend at `localhost:8188`. If your ComfyUI instance runs on a different port, update this in your `.env` file.
|
||||
|
||||
### Dev Server Configuration
|
||||
|
||||
To launch ComfyUI and have it connect to your development server:
|
||||
|
||||
```bash
|
||||
python main.py --port 8188
|
||||
```
|
||||
|
||||
### Git pre-commit hooks
|
||||
|
||||
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit hook is used to auto-format code on commit.
|
||||
|
||||
### Dev Server
|
||||
|
||||
Note: The dev server will NOT load any extension from the ComfyUI server. Only core extensions will be loaded.
|
||||
|
||||
- Start local ComfyUI backend at `localhost:8188`
|
||||
- Run `npm run dev` to start the dev server
|
||||
- Run `npm run dev:electron` to start the dev server with electron API mocked
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
After you start the dev server, you should see following logs:
|
||||
|
||||
```
|
||||
> comfyui-frontend@1.3.42 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.6 ready in 488 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://172.21.80.1:5173/
|
||||
➜ Network: http://192.168.2.20:5173/
|
||||
➜ press h + enter to show help
|
||||
```
|
||||
|
||||
Make sure your desktop machine and touch device are on the same network. On your touch device,
|
||||
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
We document significant architectural decisions using ADRs (Architecture Decision Records). See [docs/adr/](docs/adr/) for all ADRs and the template for creating new ones.
|
||||
|
||||
### Backporting Changes to Release Branches
|
||||
|
||||
When you fix a bug that affects a version in feature freeze, we use an automated backport process to apply the fix to the release candidate branch.
|
||||
|
||||
#### Real Example
|
||||
|
||||
- Subgraphs feature was released in v1.24
|
||||
- While developing v1.25, we discovered a bug in subgraphs
|
||||
- v1.24 is in feature freeze (only accepting bug fixes, no new features)
|
||||
- The fix needs to be applied to both main (v1.25) and the v1.24 release candidate
|
||||
|
||||
#### How to Backport Your Fix
|
||||
|
||||
1. Create your PR fixing the bug on `main` branch as usual
|
||||
2. Before merging, add these labels to your PR:
|
||||
- `needs-backport` - triggers the automated backport workflow
|
||||
- `1.24` - targets the `core/1.24` release candidate branch
|
||||
|
||||
3. Merge your PR normally
|
||||
4. The automated workflow will:
|
||||
- Create a new branch from `core/1.24`
|
||||
- Apply your changes to that branch
|
||||
- Open a new PR to `core/1.24`
|
||||
- Comment on your original PR with a link to the backport PR
|
||||
|
||||
#### When to Use Backporting
|
||||
|
||||
- Bug fixes for features already released
|
||||
- Security fixes
|
||||
- Critical issues affecting existing functionality
|
||||
- Never for new features (these wait for the next release cycle)
|
||||
|
||||
#### Handling Conflicts
|
||||
|
||||
If the automated cherry-pick fails due to conflicts, the workflow will comment on your PR with:
|
||||
- The list of conflicting files
|
||||
- Instructions to manually cherry-pick to the release candidate branch
|
||||
|
||||
See [PR #4616](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4616) for the actual subgraph bugfix that was backported from v1.25 to v1.24.
|
||||
|
||||
## Code Editor Configuration
|
||||
|
||||
### Recommended Setup
|
||||
|
||||
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
- `npm run test:unit` to execute all unit tests
|
||||
|
||||
### Component Tests
|
||||
|
||||
Component tests verify Vue components in `src/components/`.
|
||||
|
||||
- `npm run test:component` to execute all component tests
|
||||
|
||||
### Playwright Tests
|
||||
|
||||
Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details.
|
||||
|
||||
### Running All Tests
|
||||
|
||||
Before submitting a PR, ensure all tests pass:
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
npm run test:component
|
||||
npm run test:browser
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
- Use TypeScript for all new code
|
||||
- Avoid `any` types - use proper type definitions
|
||||
- Never use `@ts-expect-error` - fix the underlying type issue
|
||||
|
||||
### Vue 3 Patterns
|
||||
- Use Composition API for all components
|
||||
- Follow Vue 3.5+ patterns (props destructuring is reactive)
|
||||
- Use `<script setup>` syntax
|
||||
|
||||
### Styling
|
||||
- Use Tailwind CSS classes instead of custom CSS
|
||||
- Follow the existing dark theme pattern: `dark-theme:` prefix (not `dark:`)
|
||||
|
||||
### Internationalization
|
||||
- All user-facing strings must use vue-i18n
|
||||
- Add translations to `src/locales/en/main.json`
|
||||
- Use translation keys: `const { t } = useI18n(); t('key.path')`
|
||||
|
||||
## Icons
|
||||
|
||||
The project supports three types of icons, all with automatic imports (no manual imports needed):
|
||||
|
||||
1. **PrimeIcons** - Built-in PrimeVue icons using CSS classes: `<i class="pi pi-plus" />`
|
||||
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i-lucide:settings />`, `<i-mdi:folder />`
|
||||
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
|
||||
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `src/assets/icons/custom/`.
|
||||
|
||||
For detailed instructions and code examples, see [src/assets/icons/README.md](src/assets/icons/README.md).
|
||||
|
||||
## Working with litegraph.js
|
||||
|
||||
Since Aug 5, 2025, litegraph.js is now integrated directly into this repository. It was merged using git subtree to preserve the complete commit history ([PR #4667](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4667), [ADR](docs/adr/0001-merge-litegraph-into-frontend.md)).
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Issue References**: Commits from the original litegraph repository may contain issue/PR numbers (e.g., #4667) that refer to issues/PRs in the original litegraph.js repository, not this one.
|
||||
- **File Paths**: When viewing historical commits, file paths may show the original structure before the subtree merge. In those cases, just consider the paths relative to the new litegraph folder.
|
||||
- **Contributing**: All litegraph modifications should now be made directly in this repository.
|
||||
|
||||
The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is now archived.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Ensure your branch is up to date with main
|
||||
2. Run all tests and ensure they pass
|
||||
3. Create a pull request with a clear title and description
|
||||
4. Use conventional commit format for PR titles:
|
||||
- `[feat]` for new features
|
||||
- `[fix]` for bug fixes
|
||||
- `[docs]` for documentation
|
||||
- `[refactor]` for code refactoring
|
||||
- `[test]` for test additions/changes
|
||||
- `[chore]` for maintenance tasks
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```
|
||||
## Description
|
||||
Brief description of the changes
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Component tests pass
|
||||
- [ ] Browser tests pass (if applicable)
|
||||
- [ ] Manual testing completed
|
||||
|
||||
## Screenshots (if applicable)
|
||||
Add screenshots for UI changes
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
1. All PRs require at least one review
|
||||
2. Address review feedback promptly
|
||||
3. Keep PRs focused - one feature/fix per PR
|
||||
4. Large features should be discussed in an issue first
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing:
|
||||
- Check existing issues and discussions
|
||||
- Ask in our [Discord](https://discord.com/invite/comfyorg)
|
||||
- Open a new issue for clarification
|
||||
|
||||
Thank you for contributing to ComfyUI Frontend!
|
||||
201
README.md
@@ -4,8 +4,6 @@
|
||||
|
||||
**Official front-end implementation of [ComfyUI](https://github.com/comfyanonymous/ComfyUI).**
|
||||
|
||||
<!-- Testing automatic backport workflow -->
|
||||
|
||||
[![Website][website-shield]][website-url]
|
||||
[![Discord][discord-shield]][discord-url]
|
||||
[![Matrix][matrix-shield]][matrix-url]
|
||||
@@ -514,201 +512,18 @@ The selection toolbox will display the command button when items are selected:
|
||||
|
||||
## Contributing
|
||||
|
||||
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
|
||||
We welcome contributions to ComfyUI Frontend! Please see our [Contributing Guide](CONTRIBUTING.md) for:
|
||||
|
||||
Here are some ways to get involved:
|
||||
|
||||
- **Pull Requests:** Add features, fix bugs, or improve code health. Browse [issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues) for inspiration.
|
||||
- **Vote on Features:** Give a 👍 to the feature requests you care about to help us prioritize.
|
||||
- **Verify Bugs:** Try reproducing reported issues and share your results (even if the bug doesn't occur!).
|
||||
- **Community Support:** Hop into our [Discord](https://www.comfy.org/discord) to answer questions or get help.
|
||||
- **Share & Advocate:** Tell your friends, tweet about us, or share tips to support the project.
|
||||
|
||||
Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
We document significant architectural decisions using ADRs (Architecture Decision Records). See [docs/adr/](docs/adr/) for all ADRs and the template for creating new ones.
|
||||
- Ways to contribute (code, documentation, testing, community support)
|
||||
- Development setup and workflow
|
||||
- Code style guidelines
|
||||
- Testing requirements
|
||||
- How to submit pull requests
|
||||
- Backporting fixes to release branches
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance
|
||||
|
||||
- **Tech Stack**:
|
||||
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
|
||||
- [zod](https://zod.dev/) for schema validation
|
||||
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||
cd ComfyUI_frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment (optional):
|
||||
Create a `.env` file in the project root based on the provided [.env.example](.env.example) file.
|
||||
|
||||
**Note about ports**: By default, the dev server expects the ComfyUI backend at `localhost:8188`. If your ComfyUI instance runs on a different port, update this in your `.env` file.
|
||||
|
||||
### Dev Server Configuration
|
||||
|
||||
To launch ComfyUI and have it connect to your development server:
|
||||
|
||||
```bash
|
||||
python main.py --port 8188
|
||||
```
|
||||
|
||||
### Git pre-commit hooks
|
||||
|
||||
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
|
||||
hook is used to auto-format code on commit.
|
||||
|
||||
### Dev Server
|
||||
|
||||
Note: The dev server will NOT load any extension from the ComfyUI server. Only
|
||||
core extensions will be loaded.
|
||||
|
||||
- Start local ComfyUI backend at `localhost:8188`
|
||||
- Run `npm run dev` to start the dev server
|
||||
- Run `npm run dev:electron` to start the dev server with electron API mocked
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
After you start the dev server, you should see following logs:
|
||||
|
||||
```
|
||||
> comfyui-frontend@1.3.42 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.6 ready in 488 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://172.21.80.1:5173/
|
||||
➜ Network: http://192.168.2.20:5173/
|
||||
➜ press h + enter to show help
|
||||
```
|
||||
|
||||
Make sure your desktop machine and touch device are on the same network. On your touch device,
|
||||
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
|
||||
|
||||
### Recommended Code Editor Configuration
|
||||
|
||||
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Recommended MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
- `npm run test:unit` to execute all unit tests.
|
||||
|
||||
### Component Test
|
||||
|
||||
Component test verifies Vue components in `src/components/`.
|
||||
|
||||
- `npm run test:component` to execute all component tests.
|
||||
|
||||
### Playwright Test
|
||||
|
||||
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
|
||||
|
||||
### Custom Icons
|
||||
|
||||
The project supports custom SVG icons through the unplugin-icons system. Custom icons are stored in `src/assets/icons/custom/` and can be used as Vue components with the `i-comfy:` prefix.
|
||||
|
||||
For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md).
|
||||
|
||||
### litegraph.js
|
||||
|
||||
Since Aug 5, 2025, litegraph.js is now integrated directly into this repository. It was merged using git subtree to preserve the complete commit history ([PR #4667](https://github.com/Comfy-Org/ComfyUI_frontend/pull/4667), [ADR](docs/adr/0001-merge-litegraph-into-frontend.md)).
|
||||
|
||||
#### Important Notes
|
||||
|
||||
- **Issue References**: Commits from the original litegraph repository may contain issue/PR numbers (e.g., #4667) that refer to issues/PRs in the original litegraph.js repository, not this one.
|
||||
- **File Paths**: When viewing historical commits, file paths may show the original structure before the subtree merge. In those cases, just consider the paths relative to the new litegraph folder.
|
||||
- **Contributing**: All litegraph modifications should now be made directly in this repository.
|
||||
|
||||
The original litegraph repository (<https://github.com/Comfy-Org/litegraph.js>) is now archived.
|
||||
For detailed development setup, testing procedures, and technical information, please refer to [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -767,8 +767,8 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async rightClickCanvas() {
|
||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
||||
async rightClickCanvas(x: number = 10, y: number = 10) {
|
||||
await this.page.mouse.click(x, y, { button: 'right' })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -786,6 +786,164 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Core helper method for interacting with subgraph I/O slots.
|
||||
* Handles both input/output slots and both right-click/double-click actions.
|
||||
*
|
||||
* @param slotType - 'input' or 'output'
|
||||
* @param action - 'rightClick' or 'doubleClick'
|
||||
* @param slotName - Optional specific slot name to target
|
||||
* @private
|
||||
*/
|
||||
private async interactWithSubgraphSlot(
|
||||
slotType: 'input' | 'output',
|
||||
action: 'rightClick' | 'doubleClick',
|
||||
slotName?: string
|
||||
): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(
|
||||
async (params) => {
|
||||
const { slotType, action, targetSlotName } = params
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the appropriate node and slots
|
||||
const node =
|
||||
slotType === 'input'
|
||||
? currentGraph.inputNode
|
||||
: currentGraph.outputNode
|
||||
const slots =
|
||||
slotType === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${slotType} node found in subgraph`)
|
||||
}
|
||||
|
||||
if (!slots || slots.length === 0) {
|
||||
throw new Error(`No ${slotType} slots found in subgraph`)
|
||||
}
|
||||
|
||||
// Filter slots based on target name and action type
|
||||
const slotsToTry = targetSlotName
|
||||
? slots.filter((slot) => slot.name === targetSlotName)
|
||||
: action === 'rightClick'
|
||||
? slots
|
||||
: [slots[0]] // Right-click tries all, double-click uses first
|
||||
|
||||
if (slotsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetSlotName
|
||||
? `${slotType} slot '${targetSlotName}' not found`
|
||||
: `No ${slotType} slots available to try`
|
||||
)
|
||||
}
|
||||
|
||||
// Handle the interaction based on action type
|
||||
if (action === 'rightClick') {
|
||||
// Right-click: try each slot until one works
|
||||
for (const slot of slotsToTry) {
|
||||
if (!slot.pos) continue
|
||||
|
||||
const event = {
|
||||
canvasX: slot.pos[0],
|
||||
canvasY: slot.pos[1],
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return {
|
||||
success: true,
|
||||
slotName: slot.name,
|
||||
x: slot.pos[0],
|
||||
y: slot.pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action === 'doubleClick') {
|
||||
// Double-click: use first slot with bounding rect center
|
||||
const slot = slotsToTry[0]
|
||||
if (!slot.boundingRect) {
|
||||
throw new Error(`${slotType} slot bounding rect not found`)
|
||||
}
|
||||
|
||||
const rect = slot.boundingRect
|
||||
const testX = rect[0] + rect[2] / 2 // x + width/2
|
||||
const testY = rect[1] + rect[3] / 2 // y + height/2
|
||||
|
||||
const event = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0, // Left mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
if (node.onPointerDown) {
|
||||
node.onPointerDown(
|
||||
event,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly for dialog to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
return { success: true, slotName: slot.name, x: testX, y: testY }
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
},
|
||||
{ slotType, action, targetSlotName: slotName }
|
||||
)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
const actionText =
|
||||
action === 'rightClick' ? 'open context menu for' : 'double-click'
|
||||
throw new Error(
|
||||
slotName
|
||||
? `Could not ${actionText} ${slotType} slot '${slotName}'`
|
||||
: `Could not find any ${slotType} slot to ${actionText}`
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the appropriate UI element to appear
|
||||
if (action === 'rightClick') {
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
} else {
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-clicks on a subgraph input slot to open the context menu.
|
||||
* Must be called when inside a subgraph.
|
||||
@@ -800,93 +958,7 @@ export class ComfyPage {
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetInputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the input node
|
||||
const inputNode = currentGraph.inputNode
|
||||
if (!inputNode) {
|
||||
throw new Error('No input node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available inputs
|
||||
const inputs = currentGraph.inputs
|
||||
if (!inputs || inputs.length === 0) {
|
||||
throw new Error('No input slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific input if requested
|
||||
const inputsToTry = targetInputName
|
||||
? inputs.filter((inp) => inp.name === targetInputName)
|
||||
: inputs
|
||||
|
||||
if (inputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetInputName
|
||||
? `Input slot '${targetInputName}' not found`
|
||||
: 'No input slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each input slot position until one works
|
||||
for (const input of inputsToTry) {
|
||||
if (!input.pos) continue
|
||||
|
||||
const testX = input.pos[0]
|
||||
const testY = input.pos[1]
|
||||
|
||||
// Create a right-click event at the input slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the input node's right-click handler
|
||||
if (inputNode.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, inputName: input.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, inputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
inputName
|
||||
? `Could not open context menu for input slot '${inputName}'`
|
||||
: 'Could not find any input slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
return this.interactWithSubgraphSlot('input', 'rightClick', inputName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -900,93 +972,31 @@ export class ComfyPage {
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
return this.interactWithSubgraphSlot('output', 'rightClick', outputName)
|
||||
}
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Double-clicks on a subgraph input slot to rename it.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
|
||||
* If not provided, tries the first available input slot.
|
||||
* @returns Promise that resolves when the rename dialog appears
|
||||
*/
|
||||
async doubleClickSubgraphInputSlot(inputName?: string): Promise<void> {
|
||||
return this.interactWithSubgraphSlot('input', 'doubleClick', inputName)
|
||||
}
|
||||
|
||||
// Get the output node
|
||||
const outputNode = currentGraph.outputNode
|
||||
if (!outputNode) {
|
||||
throw new Error('No output node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available outputs
|
||||
const outputs = currentGraph.outputs
|
||||
if (!outputs || outputs.length === 0) {
|
||||
throw new Error('No output slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific output if requested
|
||||
const outputsToTry = targetOutputName
|
||||
? outputs.filter((out) => out.name === targetOutputName)
|
||||
: outputs
|
||||
|
||||
if (outputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetOutputName
|
||||
? `Output slot '${targetOutputName}' not found`
|
||||
: 'No output slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each output slot position until one works
|
||||
for (const output of outputsToTry) {
|
||||
if (!output.pos) continue
|
||||
|
||||
const testX = output.pos[0]
|
||||
const testY = output.pos[1]
|
||||
|
||||
// Create a right-click event at the output slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the output node's right-click handler
|
||||
if (outputNode.onPointerDown) {
|
||||
outputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, outputName: output.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, outputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
outputName
|
||||
? `Could not open context menu for output slot '${outputName}'`
|
||||
: 'Could not find any output slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
/**
|
||||
* Double-clicks on a subgraph output slot to rename it.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* @param outputName Optional name of the specific output slot to target.
|
||||
* If not provided, tries the first available output slot.
|
||||
* @returns Promise that resolves when the rename dialog appears
|
||||
*/
|
||||
async doubleClickSubgraphOutputSlot(outputName?: string): Promise<void> {
|
||||
return this.interactWithSubgraphSlot('output', 'doubleClick', outputName)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
280
browser_tests/tests/bottomPanelShortcuts.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Shortcuts', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should toggle shortcuts panel visibility', async ({ comfyPage }) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Click shortcuts toggle button in sidebar
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click toggle button again to hide
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden again
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display essentials shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials tab should be visible and active by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display shortcut categories
|
||||
await expect(
|
||||
comfyPage.page.locator('.subcategory-title').first()
|
||||
).toBeVisible()
|
||||
|
||||
// Should display some keyboard shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have workflow, node, and queue sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Workflow' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Node' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Queue' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display view controls shortcuts tab', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls tab should be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Should display view controls shortcuts
|
||||
await expect(comfyPage.page.locator('.key-badge').first()).toBeVisible()
|
||||
|
||||
// Should have view and panel controls sections
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'View' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('heading', { name: 'Panel Controls' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between shortcuts tabs', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Essentials should be active initially
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Click view controls tab
|
||||
await comfyPage.page.getByRole('tab', { name: /View Controls/i }).click()
|
||||
|
||||
// View controls should now be active
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Switch back to essentials
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).click()
|
||||
|
||||
// Essentials should be active again
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).not.toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should display formatted keyboard shortcuts', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Wait for shortcuts to load
|
||||
await comfyPage.page.waitForSelector('.key-badge')
|
||||
|
||||
// Check for common formatted keys
|
||||
const keyBadges = comfyPage.page.locator('.key-badge')
|
||||
const count = await keyBadges.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Should show formatted modifier keys
|
||||
const badgeText = await keyBadges.allTextContents()
|
||||
const hasModifiers = badgeText.some((text) =>
|
||||
['Ctrl', 'Cmd', 'Shift', 'Alt'].includes(text)
|
||||
)
|
||||
expect(hasModifiers).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should maintain panel state when switching to terminal', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel first
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Open terminal panel (should switch panels)
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Toggle Bottom Panel"]')
|
||||
.click()
|
||||
|
||||
// Panel should still be visible but showing terminal content
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Switch back to shortcuts
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should show shortcuts content again
|
||||
await expect(
|
||||
comfyPage.page.locator('[id*="tab_shortcuts-essentials"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should handle keyboard navigation', async ({ comfyPage }) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Focus the first tab
|
||||
await comfyPage.page.getByRole('tab', { name: /Essential/i }).focus()
|
||||
|
||||
// Use arrow keys to navigate between tabs
|
||||
await comfyPage.page.keyboard.press('ArrowRight')
|
||||
|
||||
// View controls tab should now have focus
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toBeFocused()
|
||||
|
||||
// Press Enter to activate the tab
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Tab should be selected
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /View Controls/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should close panel by clicking shortcuts button again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Click shortcuts button again to close
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should display shortcuts in organized columns', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Should have 3-column grid layout
|
||||
await expect(comfyPage.page.locator('.md\\:grid-cols-3')).toBeVisible()
|
||||
|
||||
// Should have multiple subcategory sections
|
||||
const subcategoryTitles = comfyPage.page.locator('.subcategory-title')
|
||||
const titleCount = await subcategoryTitles.count()
|
||||
expect(titleCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should open shortcuts panel with Ctrl+Shift+K', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Initially shortcuts panel should be hidden
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).not.toBeVisible()
|
||||
|
||||
// Press Ctrl+Shift+K to open shortcuts panel
|
||||
await comfyPage.page.keyboard.press('Control+Shift+KeyK')
|
||||
|
||||
// Shortcuts panel should now be visible
|
||||
await expect(comfyPage.page.locator('.bottom-panel')).toBeVisible()
|
||||
|
||||
// Should show essentials tab by default
|
||||
await expect(
|
||||
comfyPage.page.getByRole('tab', { name: /Essential/i })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('should open settings dialog when clicking manage shortcuts button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open shortcuts panel
|
||||
await comfyPage.page
|
||||
.locator('button[aria-label*="Keyboard Shortcuts"]')
|
||||
.click()
|
||||
|
||||
// Manage shortcuts button should be visible
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
).toBeVisible()
|
||||
|
||||
// Click manage shortcuts button
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: /Manage Shortcuts/i })
|
||||
.click()
|
||||
|
||||
// Settings dialog should open with keybinding tab
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
|
||||
// Should show keybinding settings (check for keybinding-related content)
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
161
browser_tests/tests/commandSearchBox.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Command search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
})
|
||||
|
||||
test('Can trigger command mode with ">" prefix', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
|
||||
// Type ">" to enter command mode
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
|
||||
// Verify filter button is hidden in command mode
|
||||
const filterButton = comfyPage.page.locator('.filter-button')
|
||||
await expect(filterButton).not.toBeVisible()
|
||||
|
||||
// Verify placeholder text changes
|
||||
await expect(comfyPage.searchBox.input).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search Commands')
|
||||
)
|
||||
})
|
||||
|
||||
test('Shows command list when entering command mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
// Check that commands are shown
|
||||
const firstItem = comfyPage.searchBox.dropdown.locator('li').first()
|
||||
await expect(firstItem).toBeVisible()
|
||||
|
||||
// Verify it shows a command item with icon
|
||||
const commandIcon = firstItem.locator('.item-icon')
|
||||
await expect(commandIcon).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can search and filter commands', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>save')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500) // Wait for search to complete
|
||||
|
||||
// Get all visible command items
|
||||
const items = comfyPage.searchBox.dropdown.locator('li')
|
||||
const count = await items.count()
|
||||
|
||||
// Should have filtered results
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(count).toBeLessThan(10) // Should be filtered, not showing all
|
||||
|
||||
// Verify first result contains "save"
|
||||
const firstLabel = await items.first().locator('.item-label').textContent()
|
||||
expect(firstLabel?.toLowerCase()).toContain('save')
|
||||
})
|
||||
|
||||
test('Shows keybindings for commands that have them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>undo')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Find the undo command
|
||||
const undoItem = comfyPage.searchBox.dropdown
|
||||
.locator('li')
|
||||
.filter({ hasText: 'Undo' })
|
||||
.first()
|
||||
|
||||
// Check if keybinding is shown (if configured)
|
||||
const keybinding = undoItem.locator('.item-keybinding')
|
||||
const keybindingCount = await keybinding.count()
|
||||
|
||||
// Keybinding might or might not be present depending on configuration
|
||||
if (keybindingCount > 0) {
|
||||
await expect(keybinding).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Executes command on selection', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.input.fill('>new blank')
|
||||
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Count nodes before
|
||||
const nodesBefore = await comfyPage.page
|
||||
.locator('.litegraph.litenode')
|
||||
.count()
|
||||
|
||||
// Select the new blank workflow command
|
||||
const newBlankItem = comfyPage.searchBox.dropdown
|
||||
.locator('li')
|
||||
.filter({ hasText: 'New Blank Workflow' })
|
||||
.first()
|
||||
await newBlankItem.click()
|
||||
|
||||
// Search box should close
|
||||
await expect(comfyPage.searchBox.input).not.toBeVisible()
|
||||
|
||||
// Verify workflow was cleared (no nodes)
|
||||
const nodesAfter = await comfyPage.page
|
||||
.locator('.litegraph.litenode')
|
||||
.count()
|
||||
expect(nodesAfter).toBe(0)
|
||||
})
|
||||
|
||||
test('Returns to node search when removing ">"', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
|
||||
// Enter command mode
|
||||
await comfyPage.searchBox.input.fill('>')
|
||||
await expect(comfyPage.page.locator('.filter-button')).not.toBeVisible()
|
||||
|
||||
// Return to node search by filling with empty string to trigger search
|
||||
await comfyPage.searchBox.input.fill('')
|
||||
|
||||
// Small wait for UI update
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
// Filter button should be visible again
|
||||
await expect(comfyPage.page.locator('.filter-button')).toBeVisible()
|
||||
|
||||
// Placeholder should change back
|
||||
await expect(comfyPage.searchBox.input).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search Nodes')
|
||||
)
|
||||
})
|
||||
|
||||
test('Command search is case insensitive', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
|
||||
// Search with lowercase
|
||||
await comfyPage.searchBox.input.fill('>SAVE')
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Should find save commands
|
||||
const items = comfyPage.searchBox.dropdown.locator('li')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
// Verify it found save-related commands
|
||||
const firstLabel = await items.first().locator('.item-label').textContent()
|
||||
expect(firstLabel?.toLowerCase()).toContain('save')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Locator, expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
@@ -767,6 +767,17 @@ test.describe('Viewport settings', () => {
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const changeTab = async (tab: Locator) => {
|
||||
await tab.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyMouse.move(comfyPage.emptySpace)
|
||||
|
||||
// If tooltip is visible, wait for it to hide
|
||||
await expect(
|
||||
comfyPage.page.locator('.workflow-popover-fade')
|
||||
).toHaveCount(0)
|
||||
}
|
||||
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
@@ -794,15 +805,13 @@ test.describe('Viewport settings', () => {
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabA)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotA
|
||||
)
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
await changeTab(tabB)
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotB
|
||||
)
|
||||
|
||||
@@ -48,7 +48,9 @@ test.describe('LiteGraph Native Reroute Node', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
})
|
||||
|
||||
test('Can add reroute by alt clicking on link', async ({ comfyPage }) => {
|
||||
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
157
browser_tests/tests/subgraph-rename-dialog.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const INITIAL_NAME = 'initial_slot_name'
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Slot Rename Dialog', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || graph.inputs?.[0]?.name || null
|
||||
})
|
||||
|
||||
// First rename
|
||||
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
const slot = graph.inputs?.[0]
|
||||
return {
|
||||
label: slot?.label || null,
|
||||
name: slot?.name || null,
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.rightClickSubgraphInputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
|
||||
test('Shows current output slot label in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.outputs?.[0]?.label || graph.outputs?.[0]?.name || null
|
||||
})
|
||||
|
||||
// First rename
|
||||
await comfyPage.rightClickSubgraphOutputSlot(initialOutputLabel)
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.rightClickSubgraphOutputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,182 @@ test.describe('Subgraph Operations', () => {
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename input slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
await comfyPage.doubleClickSubgraphInputSlot(initialInputLabel)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can rename output slots via double-click', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialOutputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.outputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
await comfyPage.doubleClickSubgraphOutputSlot(initialOutputLabel)
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const renamedOutputName = 'renamed_output'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, renamedOutputName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newOutputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.outputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newOutputName).toBe(renamedOutputName)
|
||||
expect(newOutputName).not.toBe(initialOutputLabel)
|
||||
})
|
||||
|
||||
test('Right-click context menu still works alongside double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
// Test that right-click still works for renaming
|
||||
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const rightClickRenamedName = 'right_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, rightClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newInputName).toBe(rightClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
|
||||
test('Can double-click on slot label text to rename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window['app']
|
||||
const graph = app.canvas.graph
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 0, // Left mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
const inputNode = graph.inputNode
|
||||
if (inputNode?.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
leftClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for dialog to appear
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
@@ -196,6 +372,68 @@ test.describe('Subgraph Operations', () => {
|
||||
const deletedNode = await comfyPage.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 69 KiB |
155
browser_tests/tests/workflowTabThumbnail.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Tab Thumbnails', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function getTab(comfyPage: ComfyPage, index: number) {
|
||||
const tab = comfyPage.page
|
||||
.locator(`.workflow-tabs .p-togglebutton`)
|
||||
.nth(index)
|
||||
return tab
|
||||
}
|
||||
|
||||
async function getTabPopover(
|
||||
comfyPage: ComfyPage,
|
||||
index: number,
|
||||
name?: string
|
||||
) {
|
||||
const tab = await getTab(comfyPage, index)
|
||||
await tab.hover()
|
||||
|
||||
const popover = comfyPage.page.locator('.workflow-popover-fade')
|
||||
await expect(popover).toHaveCount(1)
|
||||
await expect(popover).toBeVisible({ timeout: 500 })
|
||||
if (name) {
|
||||
await expect(popover).toContainText(name)
|
||||
}
|
||||
return popover
|
||||
}
|
||||
|
||||
async function getTabThumbnailImage(
|
||||
comfyPage: ComfyPage,
|
||||
index: number,
|
||||
name?: string
|
||||
) {
|
||||
const popover = await getTabPopover(comfyPage, index, name)
|
||||
const thumbnailImg = popover.locator('.workflow-preview-thumbnail img')
|
||||
return thumbnailImg
|
||||
}
|
||||
|
||||
async function getNodeThumbnailBase64(comfyPage: ComfyPage, index: number) {
|
||||
const thumbnailImg = await getTabThumbnailImage(comfyPage, index)
|
||||
const src = (await thumbnailImg.getAttribute('src'))!
|
||||
|
||||
// Convert blob to base64, need to execute a script to get the base64
|
||||
const base64 = await comfyPage.page.evaluate(async (src: string) => {
|
||||
const blob = await fetch(src).then((res) => res.blob())
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}, src)
|
||||
return base64
|
||||
}
|
||||
|
||||
test('Should show thumbnail when hovering over a non-active tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
0,
|
||||
'Unsaved Workflow'
|
||||
)
|
||||
await expect(thumbnailImg).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
1,
|
||||
'Unsaved Workflow (2)'
|
||||
)
|
||||
await expect(thumbnailImg).not.toBeVisible()
|
||||
})
|
||||
|
||||
async function addNode(comfyPage: ComfyPage, category: string, node: string) {
|
||||
const canvasArea = await comfyPage.canvas.boundingBox()
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
canvasArea!.x + canvasArea!.width - 100,
|
||||
100
|
||||
)
|
||||
await comfyPage.delay(300) // Wait for the popover to hide
|
||||
|
||||
await comfyPage.rightClickCanvas(200, 200)
|
||||
await comfyPage.page.getByText('Add Node').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText(category).click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText(node).click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Thumbnail should update when switching tabs', async ({ comfyPage }) => {
|
||||
// Wait for initial workflow to load
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Create a new workflow (tab 1) which will be empty
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
|
||||
// Tab 1 is currently active, so we can only get thumbnail for tab 0
|
||||
|
||||
// Step 1: Different tabs should show different previews
|
||||
const tab0ThumbnailWithNodes = await getNodeThumbnailBase64(comfyPage, 0)
|
||||
|
||||
// Add a node to tab 1 (current active tab)
|
||||
await addNode(comfyPage, 'loaders', 'Load Checkpoint')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Switch to tab 0 so we can get tab 1's thumbnail
|
||||
await (await getTab(comfyPage, 0)).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const tab1ThumbnailWithNode = await getNodeThumbnailBase64(comfyPage, 1)
|
||||
|
||||
// The thumbnails should be different
|
||||
expect(tab0ThumbnailWithNodes).not.toBe(tab1ThumbnailWithNode)
|
||||
|
||||
// Step 2: Switching without changes shouldn't update thumbnail
|
||||
const tab1ThumbnailBefore = await getNodeThumbnailBase64(comfyPage, 1)
|
||||
|
||||
// Switch to tab 1 and back to tab 0 without making changes
|
||||
await (await getTab(comfyPage, 1)).click()
|
||||
await comfyPage.nextFrame()
|
||||
await (await getTab(comfyPage, 0)).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const tab1ThumbnailAfter = await getNodeThumbnailBase64(comfyPage, 1)
|
||||
expect(tab1ThumbnailBefore).toBe(tab1ThumbnailAfter)
|
||||
|
||||
// Step 3: Adding another node should cause thumbnail to change
|
||||
// We're on tab 0, add a node
|
||||
await addNode(comfyPage, 'loaders', 'Load VAE')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Switch to tab 1 and back to update tab 0's thumbnail
|
||||
await (await getTab(comfyPage, 1)).click()
|
||||
|
||||
const tab0ThumbnailAfterNewNode = await getNodeThumbnailBase64(comfyPage, 0)
|
||||
|
||||
// The thumbnail should have changed after adding a node
|
||||
expect(tab0ThumbnailWithNodes).not.toBe(tab0ThumbnailAfterNewNode)
|
||||
})
|
||||
})
|
||||
362
docs/FEATURE_FLAGS.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# ComfyUI Feature Flags System
|
||||
|
||||
## Overview
|
||||
|
||||
The ComfyUI feature flags system enables capability negotiation between frontend and backend, allowing both sides to communicate their supported features and adapt behavior accordingly. This ensures backward compatibility while enabling progressive enhancement of features.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Frontend
|
||||
participant WebSocket
|
||||
participant Backend
|
||||
participant FeatureFlags Module
|
||||
|
||||
Frontend->>WebSocket: Connect
|
||||
WebSocket-->>Frontend: Connection established
|
||||
|
||||
Note over Frontend: First message must be feature flags
|
||||
Frontend->>WebSocket: Send client feature flags
|
||||
WebSocket->>Backend: Receive feature flags
|
||||
Backend->>FeatureFlags Module: Store client capabilities
|
||||
|
||||
Backend->>FeatureFlags Module: Get server features
|
||||
FeatureFlags Module-->>Backend: Return server capabilities
|
||||
Backend->>WebSocket: Send server feature flags
|
||||
WebSocket-->>Frontend: Receive server features
|
||||
|
||||
Note over Frontend,Backend: Both sides now know each other's capabilities
|
||||
|
||||
Frontend->>Frontend: Store server features
|
||||
Frontend->>Frontend: Components use useFeatureFlags()
|
||||
```
|
||||
|
||||
### Component Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Frontend
|
||||
A[clientFeatureFlags.json] --> B[api.ts]
|
||||
B --> C[WebSocket Handler]
|
||||
D[useFeatureFlags composable] --> B
|
||||
E[Vue Components] --> D
|
||||
end
|
||||
|
||||
subgraph Backend
|
||||
F[feature_flags.py] --> G[SERVER_FEATURE_FLAGS]
|
||||
H[server.py WebSocket] --> F
|
||||
I[Feature Consumers] --> F
|
||||
end
|
||||
|
||||
C <--> H
|
||||
|
||||
style A fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style G fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style D fill:#9ff,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## Feature Flag Structure
|
||||
|
||||
Feature flags are organized as a flat dictionary at the top level, with extensions nested under an `extension` object:
|
||||
|
||||
### Naming Convention
|
||||
|
||||
- **Core features**: Top-level keys (e.g., `"async_execution"`, `"supports_batch_queue"`)
|
||||
- **Client features**: Top-level keys (e.g., `"supports_preview_metadata"`)
|
||||
- **Extensions**: Nested under `"extension"` object (e.g., `extension.manager`)
|
||||
|
||||
### Structure Example
|
||||
|
||||
```json
|
||||
{
|
||||
"async_execution": true,
|
||||
"supports_batch_queue": false,
|
||||
"supports_preview_metadata": true,
|
||||
"supports_websocket_v2": false,
|
||||
"max_upload_size": 104857600,
|
||||
"extension": {
|
||||
"manager": {
|
||||
"supports_v4": true,
|
||||
"supports_ai_search": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class FeatureFlagsModule {
|
||||
+SERVER_FEATURE_FLAGS: Dict
|
||||
+get_server_features() Dict
|
||||
+supports_feature(sockets_metadata, sid, feature_name) bool
|
||||
+get_connection_feature(sockets_metadata, sid, feature_name, default) Any
|
||||
}
|
||||
|
||||
class PromptServer {
|
||||
-sockets_metadata: Dict
|
||||
+websocket_handler()
|
||||
+send()
|
||||
}
|
||||
|
||||
class FeatureConsumer {
|
||||
<<interface>>
|
||||
+check_feature()
|
||||
+use_feature()
|
||||
}
|
||||
|
||||
PromptServer --> FeatureFlagsModule
|
||||
FeatureConsumer --> FeatureFlagsModule
|
||||
```
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
The `useFeatureFlags` composable provides reactive access to feature flags, meaning components will automatically update when feature flags change (e.g., during WebSocket reconnection).
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ComfyApi {
|
||||
+serverFeatureFlags: Record~string, unknown~
|
||||
+getClientFeatureFlags() Record
|
||||
+serverSupportsFeature(name) boolean
|
||||
+getServerFeature(name, default) T
|
||||
}
|
||||
|
||||
class useFeatureFlags {
|
||||
+serverSupports(name) boolean
|
||||
+getServerFeature(name, default) T
|
||||
+createServerFeatureFlag(name) ComputedRef
|
||||
+extension: ExtensionFlags
|
||||
}
|
||||
|
||||
class VueComponent {
|
||||
<<component>>
|
||||
+setup()
|
||||
}
|
||||
|
||||
ComfyApi <-- useFeatureFlags
|
||||
VueComponent --> useFeatureFlags
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. Preview Metadata Support
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Preview Generation] --> B{supports_preview_metadata?}
|
||||
B -->|Yes| C[Send metadata with preview]
|
||||
B -->|No| D[Send preview only]
|
||||
|
||||
C --> E[Enhanced preview with node info]
|
||||
D --> F[Basic preview image]
|
||||
```
|
||||
|
||||
**Backend Usage:**
|
||||
```python
|
||||
# Check if client supports preview metadata
|
||||
if feature_flags.supports_feature(
|
||||
self.server_instance.sockets_metadata,
|
||||
self.server_instance.client_id,
|
||||
"supports_preview_metadata"
|
||||
):
|
||||
# Send enhanced preview with metadata
|
||||
metadata = {
|
||||
"node_id": node_id,
|
||||
"prompt_id": prompt_id,
|
||||
"display_node_id": display_node_id,
|
||||
"parent_node_id": parent_node_id,
|
||||
"real_node_id": real_node_id,
|
||||
}
|
||||
self.server_instance.send_sync(
|
||||
BinaryEventTypes.PREVIEW_IMAGE_WITH_METADATA,
|
||||
(image, metadata),
|
||||
self.server_instance.client_id,
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Max Upload Size
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Client File Upload] --> B[Check max_upload_size]
|
||||
B --> C{File size OK?}
|
||||
C -->|Yes| D[Upload file]
|
||||
C -->|No| E[Show error]
|
||||
|
||||
F[Backend] --> G[Set from CLI args]
|
||||
G --> H[Convert MB to bytes]
|
||||
H --> I[Include in feature flags]
|
||||
```
|
||||
|
||||
**Backend Configuration:**
|
||||
```python
|
||||
# In feature_flags.py
|
||||
SERVER_FEATURE_FLAGS = {
|
||||
"supports_preview_metadata": True,
|
||||
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Usage:**
|
||||
```typescript
|
||||
const { getServerFeature } = useFeatureFlags()
|
||||
const maxUploadSize = getServerFeature('max_upload_size', 100 * 1024 * 1024) // Default 100MB
|
||||
```
|
||||
|
||||
## Using Feature Flags
|
||||
|
||||
### Frontend Access Patterns
|
||||
|
||||
1. **Direct API access:**
|
||||
```typescript
|
||||
// Check boolean feature
|
||||
if (api.serverSupportsFeature('supports_preview_metadata')) {
|
||||
// Feature is supported
|
||||
}
|
||||
|
||||
// Get feature value with default
|
||||
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
|
||||
```
|
||||
|
||||
2. **Using the composable (recommended for reactive components):**
|
||||
```typescript
|
||||
const { serverSupports, getServerFeature, extension } = useFeatureFlags()
|
||||
|
||||
// Check feature support
|
||||
if (serverSupports('supports_preview_metadata')) {
|
||||
// Use enhanced previews
|
||||
}
|
||||
|
||||
// Use reactive convenience properties (automatically update if flags change)
|
||||
if (extension.manager.supportsV4.value) {
|
||||
// Use V4 manager API
|
||||
}
|
||||
```
|
||||
|
||||
3. **Reactive usage in templates:**
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="featureFlags.extension.manager.supportsV4">
|
||||
<!-- V4-specific UI -->
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Legacy UI -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
const featureFlags = useFeatureFlags()
|
||||
</script>
|
||||
```
|
||||
|
||||
### Backend Access Patterns
|
||||
|
||||
```python
|
||||
# Check if a specific client supports a feature
|
||||
if feature_flags.supports_feature(
|
||||
sockets_metadata,
|
||||
client_id,
|
||||
"supports_preview_metadata"
|
||||
):
|
||||
# Client supports this feature
|
||||
|
||||
# Get feature value with default
|
||||
max_size = feature_flags.get_connection_feature(
|
||||
sockets_metadata,
|
||||
client_id,
|
||||
"max_upload_size",
|
||||
100 * 1024 * 1024 # Default 100MB
|
||||
)
|
||||
```
|
||||
|
||||
## Adding New Feature Flags
|
||||
|
||||
### Backend
|
||||
|
||||
1. **For server capabilities**, add to `SERVER_FEATURE_FLAGS` in `comfy_api/feature_flags.py`:
|
||||
```python
|
||||
SERVER_FEATURE_FLAGS = {
|
||||
"supports_preview_metadata": True,
|
||||
"max_upload_size": args.max_upload_size * 1024 * 1024,
|
||||
"your_new_feature": True, # Add your flag
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use in your code:**
|
||||
```python
|
||||
if feature_flags.supports_feature(sockets_metadata, sid, "your_new_feature"):
|
||||
# Feature-specific code
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **For client capabilities**, add to `src/config/clientFeatureFlags.json`:
|
||||
```json
|
||||
{
|
||||
"supports_preview_metadata": false,
|
||||
"your_new_feature": true
|
||||
}
|
||||
```
|
||||
|
||||
2. **For extension features**, update the composable to add convenience accessors:
|
||||
```typescript
|
||||
// In useFeatureFlags.ts
|
||||
const extension = {
|
||||
manager: {
|
||||
supportsV4: computed(() => getServerFeature('extension.manager.supports_v4', false))
|
||||
},
|
||||
yourExtension: {
|
||||
supportsNewFeature: computed(() => getServerFeature('extension.yourExtension.supports_new_feature', false))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// ... existing returns
|
||||
extension
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Feature Flags
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Test Scenarios] --> B[Both support feature]
|
||||
A --> C[Only frontend supports]
|
||||
A --> D[Only backend supports]
|
||||
A --> E[Neither supports]
|
||||
|
||||
B --> F[Feature enabled]
|
||||
C --> G[Feature disabled]
|
||||
D --> H[Feature disabled]
|
||||
E --> I[Feature disabled]
|
||||
```
|
||||
|
||||
Test your feature flags with different combinations:
|
||||
- Frontend with flag + Backend with flag = Feature works
|
||||
- Frontend with flag + Backend without = Graceful degradation
|
||||
- Frontend without + Backend with flag = No feature usage
|
||||
- Neither has flag = Default behavior
|
||||
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
// In tests-ui/tests/api.featureFlags.test.ts
|
||||
it('should handle preview metadata based on feature flag', () => {
|
||||
// Mock server supports feature
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
|
||||
|
||||
// Mock server doesn't support feature
|
||||
api.serverFeatureFlags = {}
|
||||
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
|
||||
})
|
||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.5",
|
||||
"version": "1.26.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -49,6 +49,13 @@ export default defineConfig({
|
||||
grep: /@2x/ // Run all tests tagged with @2x
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium-0.5x',
|
||||
use: { ...devices['Desktop Chrome'], deviceScaleFactor: 0.5 },
|
||||
timeout: 15000,
|
||||
grep: /@0.5x/ // Run all tests tagged with @0.5x
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
|
||||
@@ -1,53 +1,148 @@
|
||||
# ComfyUI Custom Icons Guide
|
||||
# ComfyUI Icons Guide
|
||||
|
||||
This guide explains how to add and use custom SVG icons in the ComfyUI frontend.
|
||||
ComfyUI supports three types of icons that can be used throughout the interface. All icons are automatically imported - no manual imports needed!
|
||||
|
||||
## Overview
|
||||
## Quick Start - Code Examples
|
||||
|
||||
ComfyUI uses a hybrid icon system that supports:
|
||||
- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`)
|
||||
- **Iconify** - Modern icon system with 200,000+ icons
|
||||
- **Custom Icons** - Your own SVG icons
|
||||
|
||||
Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Your SVG Icon
|
||||
|
||||
Place your SVG file in the `custom/` directory:
|
||||
```
|
||||
src/assets/icons/custom/
|
||||
└── your-icon.svg
|
||||
```
|
||||
|
||||
### 2. Use in Components
|
||||
### 1. PrimeIcons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Use as a Vue component -->
|
||||
<i-comfy:your-icon />
|
||||
|
||||
<!-- In a PrimeVue button -->
|
||||
<Button>
|
||||
<!-- Basic usage -->
|
||||
<i class="pi pi-plus" />
|
||||
<i class="pi pi-cog" />
|
||||
<i class="pi pi-check text-green-500" />
|
||||
|
||||
<!-- In PrimeVue components -->
|
||||
<button icon="pi pi-save" label="Save" />
|
||||
<button icon="pi pi-times" severity="danger" />
|
||||
</template>
|
||||
```
|
||||
|
||||
[Browse all PrimeIcons →](https://primevue.org/icons/#list)
|
||||
|
||||
### 2. Iconify Icons (Recommended)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Primary icon set: Lucide -->
|
||||
<i-lucide:download />
|
||||
<i-lucide:settings />
|
||||
<i-lucide:workflow class="text-2xl" />
|
||||
|
||||
<!-- Other popular icon sets -->
|
||||
<i-mdi:folder-open />
|
||||
<!-- Material Design Icons -->
|
||||
<i-heroicons:document-text />
|
||||
<!-- Heroicons -->
|
||||
<i-tabler:brand-github />
|
||||
<!-- Tabler Icons -->
|
||||
<i-carbon:cloud-upload />
|
||||
<!-- Carbon Icons -->
|
||||
|
||||
<!-- With styling -->
|
||||
<i-lucide:save class="w-6 h-6 text-blue-500" />
|
||||
</template>
|
||||
```
|
||||
|
||||
[Browse 200,000+ icons →](https://icon-sets.iconify.design/)
|
||||
|
||||
### 3. Custom Icons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Your custom SVG icons from src/assets/icons/custom/ -->
|
||||
<i-comfy:workflow />
|
||||
<i-comfy:node-tree />
|
||||
<i-comfy:my-custom-icon class="text-xl" />
|
||||
|
||||
<!-- In PrimeVue button -->
|
||||
<Button severity="secondary">
|
||||
<template #icon>
|
||||
<i-comfy:your-icon />
|
||||
<i-comfy:workflow />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## SVG Requirements
|
||||
## Icon Usage Patterns
|
||||
|
||||
### File Naming
|
||||
- Use kebab-case: `workflow-icon.svg`, `node-tree.svg`
|
||||
- Avoid special characters and spaces
|
||||
- The filename becomes the icon name
|
||||
### In Buttons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- PrimeIcon in button (simple) -->
|
||||
<Button icon="pi pi-check" label="Confirm" />
|
||||
|
||||
<!-- Iconify/Custom in button (template) -->
|
||||
<Button>
|
||||
<template #icon>
|
||||
<i-lucide:save />
|
||||
</template>
|
||||
Save File
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Conditional Icons
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i-lucide:eye v-if="isVisible" />
|
||||
<i-lucide:eye-off v-else />
|
||||
|
||||
<!-- Or with ternary -->
|
||||
<component :is="isLocked ? 'i-lucide:lock' : 'i-lucide:lock-open'" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### With Tooltips
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<i-lucide:info
|
||||
v-tooltip="'Click for more information'"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Using Iconify Icons
|
||||
|
||||
### Finding Icons
|
||||
|
||||
1. Visit [Iconify Icon Sets](https://icon-sets.iconify.design/)
|
||||
2. Search or browse collections
|
||||
3. Click on any icon to get its name
|
||||
4. Use with `i-[collection]:[icon-name]` format
|
||||
|
||||
### Popular Collections
|
||||
|
||||
- **Lucide** (`i-lucide:`) - Our primary icon set, clean and consistent
|
||||
- **Material Design Icons** (`i-mdi:`) - Comprehensive Material Design icons
|
||||
- **Heroicons** (`i-heroicons:`) - Beautiful hand-crafted SVG icons
|
||||
- **Tabler** (`i-tabler:`) - 3000+ free SVG icons
|
||||
- **Carbon** (`i-carbon:`) - IBM's design system icons
|
||||
|
||||
## Adding Custom Icons
|
||||
|
||||
### 1. Add Your SVG
|
||||
|
||||
Place your SVG file in `src/assets/icons/custom/`:
|
||||
|
||||
```
|
||||
src/assets/icons/custom/
|
||||
├── workflow-duplicate.svg
|
||||
├── node-preview.svg
|
||||
└── your-icon.svg
|
||||
```
|
||||
|
||||
### 2. SVG Format Requirements
|
||||
|
||||
### SVG Format
|
||||
```xml
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="..." />
|
||||
<!-- Use currentColor for theme compatibility -->
|
||||
<path fill="currentColor" d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
@@ -57,59 +152,98 @@ src/assets/icons/custom/
|
||||
- Use `currentColor` for theme-aware icons
|
||||
- Keep SVGs optimized and simple
|
||||
|
||||
### Color Theming
|
||||
### 3. Use Immediately
|
||||
|
||||
For icons that adapt to the current theme, use `currentColor`:
|
||||
```vue
|
||||
<template>
|
||||
<i-comfy:your-icon />
|
||||
</template>
|
||||
```
|
||||
|
||||
No imports needed - icons are auto-discovered!
|
||||
|
||||
## Icon Guidelines
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: `kebab-case.svg` (workflow-icon.svg)
|
||||
- **Usage**: `<i-comfy:workflow-icon />`
|
||||
|
||||
### Size & Styling
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Size with Tailwind classes -->
|
||||
<i-lucide:plus class="w-4 h-4" />
|
||||
<!-- 16px -->
|
||||
<i-lucide:plus class="w-6 h-6" />
|
||||
<!-- 24px (default) -->
|
||||
<i-lucide:plus class="w-8 h-8" />
|
||||
<!-- 32px -->
|
||||
|
||||
<!-- Or text size -->
|
||||
<i-lucide:plus class="text-sm" />
|
||||
<i-lucide:plus class="text-2xl" />
|
||||
|
||||
<!-- Colors -->
|
||||
<i-lucide:check class="text-green-500" />
|
||||
<i-lucide:x class="text-red-500" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Theme Compatibility
|
||||
|
||||
Always use `currentColor` in SVGs for automatic theme adaptation:
|
||||
|
||||
```xml
|
||||
<!-- ✅ Good: Uses currentColor -->
|
||||
<!-- ✅ Good: Adapts to light/dark theme -->
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" fill="none" d="..." />
|
||||
<path fill="currentColor" d="..." />
|
||||
</svg>
|
||||
|
||||
<!-- ❌ Bad: Hardcoded colors -->
|
||||
<!-- ❌ Bad: Fixed colors -->
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path stroke="white" fill="black" d="..." />
|
||||
<path fill="#000000" d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
## Migration Guide
|
||||
|
||||
### From PrimeIcons to Iconify/Custom
|
||||
|
||||
### Basic Icon
|
||||
```vue
|
||||
<i-comfy:workflow />
|
||||
<template>
|
||||
<!-- Before -->
|
||||
<Button icon="pi pi-download" />
|
||||
|
||||
<!-- After -->
|
||||
<Button>
|
||||
<template #icon>
|
||||
<i-lucide:download />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### With Classes
|
||||
```vue
|
||||
<i-comfy:workflow class="text-2xl text-blue-500" />
|
||||
```
|
||||
### From Inline SVG to Custom Icon
|
||||
|
||||
### In Buttons
|
||||
```vue
|
||||
<Button severity="secondary" text>
|
||||
<template #icon>
|
||||
<i-comfy:workflow />
|
||||
</template>
|
||||
</Button>
|
||||
```
|
||||
<template>
|
||||
<!-- Before: Inline SVG -->
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path d="..." />
|
||||
</svg>
|
||||
|
||||
### Conditional Icons
|
||||
```vue
|
||||
<template #icon>
|
||||
<i-comfy:workflow v-if="isWorkflow" />
|
||||
<i-comfy:node v-else />
|
||||
<!-- After: Save as custom/my-icon.svg and use -->
|
||||
<i-comfy:my-icon class="w-6 h-6" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How It Works
|
||||
### Auto-Import System
|
||||
|
||||
1. **unplugin-icons** automatically discovers SVG files in `custom/`
|
||||
2. During build, SVGs are converted to Vue components
|
||||
3. Components are tree-shaken - only used icons are bundled
|
||||
4. The `i-` prefix and `comfy:` namespace identify custom icons
|
||||
Icons are automatically imported using `unplugin-icons` - no manual imports needed! Just use the icon component directly.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -119,17 +253,18 @@ The icon system is configured in `vite.config.mts`:
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
'comfy': FileSystemIconLoader('src/assets/icons/custom'),
|
||||
comfy: FileSystemIconLoader('src/assets/icons/custom')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### TypeScript Support
|
||||
|
||||
Icons are automatically typed. If TypeScript doesn't recognize a new icon:
|
||||
1. Restart your dev server
|
||||
2. Check that the SVG file is valid
|
||||
3. Ensure the filename follows kebab-case convention
|
||||
Icons are fully typed. If TypeScript doesn't recognize a new custom icon:
|
||||
|
||||
1. Restart the dev server
|
||||
2. Ensure the SVG file is valid
|
||||
3. Check filename follows kebab-case
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -157,22 +292,6 @@ Icons are automatically typed. If TypeScript doesn't recognize a new icon:
|
||||
4. **Theme support**: Always use `currentColor` for adaptable icons
|
||||
5. **Test both themes**: Verify icons look good in light and dark modes
|
||||
|
||||
## Migration from PrimeIcons
|
||||
|
||||
When replacing a PrimeIcon with a custom icon:
|
||||
|
||||
```vue
|
||||
<!-- Before: PrimeIcon -->
|
||||
<Button icon="pi pi-box" />
|
||||
|
||||
<!-- After: Custom icon -->
|
||||
<Button>
|
||||
<template #icon>
|
||||
<i-comfy:workflow />
|
||||
</template>
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Adding Icon Collections
|
||||
|
||||
To add an entire icon set from npm:
|
||||
@@ -181,4 +300,11 @@ To add an entire icon set from npm:
|
||||
2. Configure in `vite.config.mts`
|
||||
3. Use with the appropriate prefix
|
||||
|
||||
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
|
||||
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.
|
||||
|
||||
## Resources
|
||||
|
||||
- [PrimeIcons List](https://primevue.org/icons/#list)
|
||||
- [Iconify Icon Browser](https://icon-sets.iconify.design/)
|
||||
- [Lucide Icons](https://lucide.dev/icons/)
|
||||
- [unplugin-icons docs](https://github.com/unplugin/unplugin-icons)
|
||||
|
||||
@@ -11,18 +11,33 @@
|
||||
class="p-3 border-none"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ tab.title.toUpperCase() }}
|
||||
{{
|
||||
shouldCapitalizeTab(tab.id)
|
||||
? tab.title.toUpperCase()
|
||||
: tab.title
|
||||
}}
|
||||
</span>
|
||||
</Tab>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
:label="$t('shortcuts.manageShortcuts')"
|
||||
icon="pi pi-cog"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="openKeybindingSettings"
|
||||
/>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="closeBottomPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
@@ -44,9 +59,32 @@ import Button from 'primevue/button'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
const activeTabId = bottomPanelStore.activeBottomPanelTabId
|
||||
return (
|
||||
activeTabId === 'shortcuts-essentials' ||
|
||||
activeTabId === 'shortcuts-view-controls'
|
||||
)
|
||||
})
|
||||
|
||||
const shouldCapitalizeTab = (tabId: string): boolean => {
|
||||
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
bottomPanelStore.activePanel = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="essentialsCommands"
|
||||
:subcategories="essentialsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
ESSENTIALS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const essentialsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'essentials')
|
||||
)
|
||||
|
||||
const { subcategories: essentialsSubcategories } = useCommandSubcategories(
|
||||
essentialsCommands,
|
||||
ESSENTIALS_CONFIG
|
||||
)
|
||||
</script>
|
||||
119
src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="shortcuts-list flex justify-center">
|
||||
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
|
||||
<div
|
||||
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
|
||||
:key="subcategory"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h3
|
||||
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
|
||||
>
|
||||
{{ getSubcategoryTitle(subcategory) }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="command in subcategoryCommands"
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ command.getTranslatedLabel() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keybinding-display flex-shrink-0">
|
||||
<div
|
||||
class="keybinding-combo flex gap-1"
|
||||
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
|
||||
>
|
||||
<span
|
||||
v-for="key in command.keybinding!.combo.getKeySequences()"
|
||||
:key="key"
|
||||
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
|
||||
>
|
||||
{{ formatKey(key) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { subcategories } = defineProps<{
|
||||
commands: ComfyCommandImpl[]
|
||||
subcategories: Record<string, ComfyCommandImpl[]>
|
||||
}>()
|
||||
|
||||
const filteredSubcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const [subcategory, commands] of Object.entries(subcategories)) {
|
||||
result[subcategory] = commands.filter((cmd) => !!cmd.keybinding)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const getSubcategoryTitle = (subcategory: string): string => {
|
||||
const titleMap: Record<string, string> = {
|
||||
workflow: t('shortcuts.subcategories.workflow'),
|
||||
node: t('shortcuts.subcategories.node'),
|
||||
queue: t('shortcuts.subcategories.queue'),
|
||||
view: t('shortcuts.subcategories.view'),
|
||||
'panel-controls': t('shortcuts.subcategories.panelControls')
|
||||
}
|
||||
|
||||
return titleMap[subcategory] || subcategory
|
||||
}
|
||||
|
||||
const formatKey = (key: string): string => {
|
||||
const keyMap: Record<string, string> = {
|
||||
Control: 'Ctrl',
|
||||
Meta: 'Cmd',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Backspace: '⌫',
|
||||
Delete: '⌦',
|
||||
Enter: '↵',
|
||||
Escape: 'Esc',
|
||||
Tab: '⇥',
|
||||
' ': 'Space'
|
||||
}
|
||||
|
||||
return keyMap[key] || key
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subcategory-title {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.key-badge {
|
||||
background-color: var(--p-surface-200);
|
||||
border: 1px solid var(--p-surface-300);
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark-theme .key-badge {
|
||||
background-color: var(--p-surface-600);
|
||||
border-color: var(--p-surface-500);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="viewControlsCommands"
|
||||
:subcategories="viewControlsSubcategories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
VIEW_CONTROLS_CONFIG,
|
||||
useCommandSubcategories
|
||||
} from '@/composables/bottomPanelTabs/useCommandSubcategories'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ShortcutsList from './ShortcutsList.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const viewControlsCommands = computed(() =>
|
||||
commandStore.commands.filter((cmd) => cmd.category === 'view-controls')
|
||||
)
|
||||
|
||||
const { subcategories: viewControlsSubcategories } = useCommandSubcategories(
|
||||
viewControlsCommands,
|
||||
VIEW_CONTROLS_CONFIG
|
||||
)
|
||||
</script>
|
||||
124
src/components/common/LazyImage.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<img
|
||||
v-show="isImageLoaded"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
>
|
||||
<i class="pi pi-image text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isImageLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
const cachedSrc = ref<string | undefined>(undefined)
|
||||
|
||||
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
|
||||
|
||||
// Use intersection observer to detect when the image container comes into view
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
isIntersecting.value = entry?.isIntersecting ?? false
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
// Only start loading the image when it's in view
|
||||
const shouldLoad = computed(() => isIntersecting.value)
|
||||
|
||||
watch(
|
||||
shouldLoad,
|
||||
async (shouldLoad) => {
|
||||
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
|
||||
try {
|
||||
const cachedMedia = await getCachedMedia(src)
|
||||
if (cachedMedia.error) {
|
||||
hasError.value = true
|
||||
} else if (cachedMedia.objectUrl) {
|
||||
const acquiredUrl = acquireUrl(src)
|
||||
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
|
||||
} else {
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load cached media:', error)
|
||||
cachedSrc.value = src
|
||||
}
|
||||
} else if (!shouldLoad) {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
// Hide image when out of view
|
||||
isImageLoaded.value = false
|
||||
cachedSrc.value = undefined
|
||||
hasError.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onImageLoad = () => {
|
||||
isImageLoaded.value = true
|
||||
hasError.value = false
|
||||
}
|
||||
|
||||
const onImageError = () => {
|
||||
hasError.value = true
|
||||
isImageLoaded.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cachedSrc.value?.startsWith('blob:')) {
|
||||
releaseUrl(src)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -139,7 +139,6 @@ import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
@@ -149,7 +148,6 @@ import {
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
@@ -161,7 +159,6 @@ const filters = ref({
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybindingService = useKeybindingService()
|
||||
const commandStore = useCommandStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ICommandData {
|
||||
id: string
|
||||
@@ -173,10 +170,7 @@ interface ICommandData {
|
||||
const commandsData = computed<ICommandData[]>(() => {
|
||||
return Object.values(commandStore.commands).map((command) => ({
|
||||
id: command.id,
|
||||
label: t(
|
||||
`commands.${normalizeI18nKey(command.id)}.label`,
|
||||
command.label ?? ''
|
||||
),
|
||||
label: command.getTranslatedLabel(),
|
||||
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
|
||||
source: command.source
|
||||
}))
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' (Alt + m)'"
|
||||
v-tooltip.left="minimapTooltip"
|
||||
severity="secondary"
|
||||
:icon="'pi pi-map'"
|
||||
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
|
||||
@@ -79,15 +79,24 @@ import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
const minimapTooltip = computed(() => {
|
||||
const baseText = t('graphCanvasMenu.toggleMinimap')
|
||||
const keybinding = keybindingStore.getKeybindingByCommandId(
|
||||
'Comfy.Canvas.ToggleMinimap'
|
||||
)
|
||||
return keybinding ? `${baseText} (${keybinding.combo.toString()})` : baseText
|
||||
})
|
||||
const linkHidden = computed(
|
||||
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
class="litegraph-minimap absolute right-[90px] z-[1000]"
|
||||
:class="{
|
||||
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
|
||||
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
|
||||
}"
|
||||
:style="containerStyles"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<canvas
|
||||
@@ -25,9 +29,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const minimap = useMinimap()
|
||||
const canvasStore = useCanvasStore()
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const {
|
||||
initialized,
|
||||
@@ -40,9 +46,9 @@ const {
|
||||
height,
|
||||
init,
|
||||
destroy,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel
|
||||
} = minimap
|
||||
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { provide, readonly, ref, watch } from 'vue'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
@@ -27,6 +28,13 @@ const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
|
||||
const visible = ref(false)
|
||||
const showBorder = ref(false)
|
||||
// Increment counter to notify child components of position/visibility change
|
||||
// This does not include viewport changes.
|
||||
const overlayUpdateCount = ref(0)
|
||||
provide(SelectionOverlayInjectionKey, {
|
||||
visible: readonly(visible),
|
||||
updateCount: readonly(overlayUpdateCount)
|
||||
})
|
||||
|
||||
const positionSelectionOverlay = () => {
|
||||
const selectableItems = getSelectableItems()
|
||||
@@ -52,6 +60,7 @@ whenever(
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
positionSelectionOverlay()
|
||||
overlayUpdateCount.value++
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
})
|
||||
},
|
||||
@@ -71,6 +80,7 @@ watch(
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
positionSelectionOverlay()
|
||||
overlayUpdateCount.value++
|
||||
})
|
||||
} else {
|
||||
// Selection change update to visible state is delayed by a frame. Here
|
||||
@@ -78,6 +88,7 @@ watch(
|
||||
// the initial selection and dragging happens at the same time.
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = false
|
||||
overlayUpdateCount.value++
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<Panel
|
||||
class="selection-toolbox absolute left-1/2 rounded-lg"
|
||||
:class="{ 'animate-slide-up': shouldAnimate }"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-0 flex flex-row'
|
||||
@@ -27,7 +28,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
@@ -40,16 +41,24 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||
import { useRetriggerableAnimation } from '@/composables/element/useRetriggerableAnimation'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { SelectionOverlayInjectionKey } from '@/types/selectionOverlayTypes'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const selectionOverlayState = inject(SelectionOverlayInjectionKey)
|
||||
const { shouldAnimate } = useRetriggerableAnimation(
|
||||
selectionOverlayState?.updateCount,
|
||||
{ animateOnMount: true }
|
||||
)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
canvasStore.selectedItems
|
||||
@@ -71,4 +80,20 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
.selection-toolbox {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
}
|
||||
|
||||
/* Slide up animation using CSS animation */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -188,16 +188,13 @@ const showVersionUpdates = computed(() =>
|
||||
settingStore.get('Comfy.Notification.ShowVersionUpdates')
|
||||
)
|
||||
|
||||
const moreMenuItem = computed(() =>
|
||||
menuItems.value.find((item) => item.key === 'more')
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const moreItems: MenuItem[] = [
|
||||
const moreItems = computed<MenuItem[]>(() => {
|
||||
const allMoreItems: MenuItem[] = [
|
||||
{
|
||||
key: 'desktop-guide',
|
||||
type: 'item',
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
|
||||
emit('close')
|
||||
@@ -230,6 +227,19 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
}
|
||||
]
|
||||
|
||||
// Filter for visible items only
|
||||
return allMoreItems.filter((item) => item.visible !== false)
|
||||
})
|
||||
|
||||
const hasVisibleMoreItems = computed(() => {
|
||||
return !!moreItems.value.length
|
||||
})
|
||||
|
||||
const moreMenuItem = computed(() =>
|
||||
menuItems.value.find((item) => item.key === 'more')
|
||||
)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'docs',
|
||||
@@ -276,8 +286,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
type: 'item',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
visible: hasVisibleMoreItems.value,
|
||||
action: () => {}, // No action for more item
|
||||
items: moreItems
|
||||
items: moreItems.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
50
src/components/searchbox/CommandSearchItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-3 py-2 w-full">
|
||||
<span
|
||||
class="flex-shrink-0 w-5 text-center text-muted item-icon"
|
||||
:class="command.icon ?? 'pi pi-chevron-right'"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap item-label"
|
||||
>
|
||||
<span
|
||||
v-html="highlightQuery(command.getTranslatedLabel(), currentQuery)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="command.keybinding"
|
||||
class="flex-shrink-0 text-xs px-1.5 py-0.5 border rounded font-mono keybinding-badge"
|
||||
>
|
||||
{{ command.keybinding.combo.toString() }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
import { highlightQuery } from '@/utils/formatUtil'
|
||||
|
||||
const { command, currentQuery } = defineProps<{
|
||||
command: ComfyCommandImpl
|
||||
currentQuery: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.highlight) {
|
||||
background-color: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color);
|
||||
font-weight: bold;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
.keybinding-badge {
|
||||
border-color: var(--p-content-border-color);
|
||||
background-color: var(--p-content-hover-background);
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96"
|
||||
>
|
||||
<div
|
||||
v-if="enableNodePreview"
|
||||
v-if="enableNodePreview && !isCommandMode"
|
||||
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
|
||||
>
|
||||
<NodePreview
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!isCommandMode"
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
class="filter-button z-10"
|
||||
@@ -49,13 +50,24 @@
|
||||
auto-option-focus
|
||||
force-selection
|
||||
multiple
|
||||
:option-label="'display_name'"
|
||||
:option-label="getOptionLabel"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@option-select="onOptionSelect($event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<NodeSearchItem :node-def="option" :current-query="currentQuery" />
|
||||
<!-- Command search item, Remove the '>' prefix from the query -->
|
||||
<CommandSearchItem
|
||||
v-if="isCommandMode"
|
||||
:command="option"
|
||||
:current-query="currentQuery.substring(1)"
|
||||
/>
|
||||
<NodeSearchItem
|
||||
v-else
|
||||
:node-def="option"
|
||||
:current-query="currentQuery"
|
||||
/>
|
||||
</template>
|
||||
<!-- FilterAndValue -->
|
||||
<template #chip="{ value }">
|
||||
@@ -80,13 +92,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
|
||||
import CommandSearchItem from '@/components/searchbox/CommandSearchItem.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import { CommandSearchService } from '@/services/commandSearchService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ComfyNodeDefImpl,
|
||||
useNodeDefStore,
|
||||
@@ -99,6 +114,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const enableNodePreview = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
||||
@@ -111,18 +127,50 @@ const { filters, searchLimit = 64 } = defineProps<{
|
||||
|
||||
const nodeSearchFilterVisible = ref(false)
|
||||
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
|
||||
const suggestions = ref<ComfyNodeDefImpl[]>([])
|
||||
const suggestions = ref<ComfyNodeDefImpl[] | ComfyCommandImpl[]>([])
|
||||
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
|
||||
const currentQuery = ref('')
|
||||
const isCommandMode = ref(false)
|
||||
|
||||
// Initialize command search service
|
||||
const commandSearchService = ref<CommandSearchService | null>(null)
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (isCommandMode.value) {
|
||||
return t('g.searchCommands', 'Search commands') + '...'
|
||||
}
|
||||
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
// Initialize command search service with commands
|
||||
watch(
|
||||
() => commandStore.commands,
|
||||
(commands) => {
|
||||
commandSearchService.value = new CommandSearchService(commands)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const search = (query: string) => {
|
||||
const queryIsEmpty = query === '' && filters.length === 0
|
||||
currentQuery.value = query
|
||||
|
||||
// Check if we're in command mode (query starts with ">")
|
||||
if (query.startsWith('>')) {
|
||||
isCommandMode.value = true
|
||||
if (commandSearchService.value) {
|
||||
suggestions.value = commandSearchService.value.searchCommands(query, {
|
||||
limit: searchLimit
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Normal node search mode
|
||||
isCommandMode.value = false
|
||||
const queryIsEmpty = query === '' && filters.length === 0
|
||||
suggestions.value = queryIsEmpty
|
||||
? nodeFrequencyStore.topNodeDefs
|
||||
: [
|
||||
@@ -132,7 +180,18 @@ const search = (query: string) => {
|
||||
]
|
||||
}
|
||||
|
||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'addFilter',
|
||||
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
): void
|
||||
(
|
||||
e: 'removeFilter',
|
||||
filterAndValue: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
): void
|
||||
(e: 'addNode', nodeDef: ComfyNodeDefImpl): void
|
||||
(e: 'executeCommand', command: ComfyCommandImpl): void
|
||||
}>()
|
||||
|
||||
let inputElement: HTMLInputElement | null = null
|
||||
const reFocusInput = async () => {
|
||||
@@ -160,11 +219,47 @@ const onRemoveFilter = async (
|
||||
await reFocusInput()
|
||||
}
|
||||
const setHoverSuggestion = (index: number) => {
|
||||
if (index === -1) {
|
||||
if (index === -1 || isCommandMode.value) {
|
||||
hoveredSuggestion.value = null
|
||||
return
|
||||
}
|
||||
const value = suggestions.value[index]
|
||||
const value = suggestions.value[index] as ComfyNodeDefImpl
|
||||
hoveredSuggestion.value = value
|
||||
}
|
||||
|
||||
const onOptionSelect = (option: ComfyNodeDefImpl | ComfyCommandImpl) => {
|
||||
if (isCommandMode.value) {
|
||||
emit('executeCommand', option as ComfyCommandImpl)
|
||||
} else {
|
||||
emit('addNode', option as ComfyNodeDefImpl)
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionLabel = (
|
||||
option: ComfyNodeDefImpl | ComfyCommandImpl
|
||||
): string => {
|
||||
if ('display_name' in option) {
|
||||
return option.display_name
|
||||
}
|
||||
return option.label || option.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles direct input changes on the AutoCompletePlus component.
|
||||
* This ensures search mode switching works properly when users clear the input
|
||||
* or modify it directly, as the @complete event may not always trigger.
|
||||
*
|
||||
* @param event - The input event from the AutoCompletePlus component
|
||||
* @note Known issue on empty input complete state:
|
||||
* https://github.com/Comfy-Org/ComfyUI_frontend/issues/4887
|
||||
*/
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const inputValue = target.value
|
||||
|
||||
// Trigger search to handle mode switching between node and command search
|
||||
if (inputValue === '') {
|
||||
search('')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@execute-command="executeCommand"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -62,6 +64,7 @@ let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const { visible } = storeToRefs(useSearchBoxStore())
|
||||
const dismissable = ref(true)
|
||||
@@ -109,6 +112,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
const executeCommand = async (command: ComfyCommandImpl) => {
|
||||
// Close the dialog immediately
|
||||
closeDialog()
|
||||
|
||||
// Execute the command
|
||||
await commandStore.execute(command.id)
|
||||
}
|
||||
|
||||
const newSearchBoxEnabled = computed(
|
||||
() => settingStore.get('Comfy.NodeSearchBoxImpl') === 'default'
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarShortcutsToggleButton />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -32,6 +33,7 @@ import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
:selected="bottomPanelStore.activePanel == 'terminal'"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
44
src/components/sidebar/SidebarShortcutsToggleButton.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="
|
||||
$t('shortcuts.keyboardShortcuts') +
|
||||
' (' +
|
||||
formatKeySequence(command.keybinding!.combo.getKeySequences()) +
|
||||
')'
|
||||
"
|
||||
:selected="isShortcutsPanelVisible"
|
||||
@click="toggleShortcutsPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:keyboard />
|
||||
</template>
|
||||
</SidebarIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const command = useCommandStore().getCommand(
|
||||
'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
)
|
||||
|
||||
const isShortcutsPanelVisible = computed(
|
||||
() => bottomPanelStore.activePanel === 'shortcuts'
|
||||
)
|
||||
|
||||
const toggleShortcutsPanel = () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
|
||||
const formatKeySequence = (sequences: string[]): string => {
|
||||
return sequences
|
||||
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
|
||||
.join(' + ')
|
||||
}
|
||||
</script>
|
||||
64
src/components/templates/TemplateSearchBar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="relative w-full p-4">
|
||||
<div class="h-12 flex items-center gap-4 justify-between">
|
||||
<div class="flex-1 max-w-md">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:placeholder="$t('templateWorkflows.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="200"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<small
|
||||
v-if="searchQuery && filteredCount !== null"
|
||||
class="text-color-secondary"
|
||||
>
|
||||
{{ $t('g.resultsCount', { count: filteredCount }) }}
|
||||
</small>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
:label="$t('g.clearFilters')"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const { filteredCount } = defineProps<{
|
||||
filteredCount?: number | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearFilters: []
|
||||
}>()
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = ''
|
||||
emit('clearFilters')
|
||||
}
|
||||
</script>
|
||||
30
src/components/templates/TemplateWorkflowCardSkeleton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative overflow-hidden rounded-t-lg">
|
||||
<Skeleton width="16rem" height="12rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<Skeleton width="80%" height="1.25rem" class="mb-2" />
|
||||
<Skeleton width="100%" height="0.875rem" class="mb-1" />
|
||||
<Skeleton width="90%" height="0.875rem" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from 'primevue/card'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
@@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-search-bar"></div>',
|
||||
props: ['searchQuery', 'filteredCount'],
|
||||
emits: ['update:searchQuery', 'clearFilters']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-skeleton"></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: () => 'grid'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useIntersectionObserver', () => ({
|
||||
useIntersectionObserver: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLazyPagination', () => ({
|
||||
useLazyPagination: (items: any) => ({
|
||||
paginatedItems: items,
|
||||
isLoading: { value: false },
|
||||
hasMoreItems: { value: false },
|
||||
loadNextPage: vi.fn(),
|
||||
reset: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTemplateFiltering', () => ({
|
||||
useTemplateFiltering: (templates: any) => ({
|
||||
searchQuery: { value: '' },
|
||||
filteredTemplates: templates,
|
||||
filteredCount: { value: templates.value?.length || 0 }
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
@@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => {
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
templateWorkflows: {
|
||||
loadingMore: 'Loading more...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
@@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
|
||||
],
|
||||
loading: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<template>
|
||||
<DataView
|
||||
:value="templates"
|
||||
:value="displayTemplates"
|
||||
:layout="layout"
|
||||
data-key="name"
|
||||
:lazy="true"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr]"
|
||||
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
|
||||
pt:content="p-2 overflow-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg">{{ title }}</h2>
|
||||
<SelectButton
|
||||
v-model="layout"
|
||||
:options="['grid', 'list']"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<TemplateSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:filtered-count="filteredCount"
|
||||
@clear-filters="() => reset()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,18 +40,35 @@
|
||||
</template>
|
||||
|
||||
<template #grid="{ items }">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
|
||||
>
|
||||
<TemplateWorkflowCard
|
||||
v-for="template in items"
|
||||
:key="template.name"
|
||||
:source-module="sourceModule"
|
||||
:template="template"
|
||||
:loading="loading === template.name"
|
||||
:category-title="categoryTitle"
|
||||
@load-workflow="onLoadWorkflow"
|
||||
/>
|
||||
<TemplateWorkflowCardSkeleton
|
||||
v-for="n in shouldUsePagination && isLoadingMore
|
||||
? skeletonCount
|
||||
: 0"
|
||||
:key="`skeleton-${n}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldUsePagination && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ t('templateWorkflows.loadingMore') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
@@ -54,12 +78,21 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import DataView from 'primevue/dataview'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
|
||||
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
|
||||
title: string
|
||||
sourceModule: string
|
||||
categoryTitle: string
|
||||
@@ -72,6 +105,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
|
||||
'grid'
|
||||
)
|
||||
|
||||
const skeletonCount = 6
|
||||
const loadTrigger = ref<HTMLElement | null>(null)
|
||||
|
||||
const templatesRef = computed(() => templates || [])
|
||||
|
||||
const { searchQuery, filteredTemplates, filteredCount } =
|
||||
useTemplateFiltering(templatesRef)
|
||||
|
||||
// When searching, show all results immediately without pagination
|
||||
// When not searching, use lazy pagination
|
||||
const shouldUsePagination = computed(() => !searchQuery.value.trim())
|
||||
|
||||
// Lazy pagination setup using filtered templates
|
||||
const {
|
||||
paginatedItems: paginatedTemplates,
|
||||
isLoading: isLoadingMore,
|
||||
hasMoreItems: hasMoreTemplates,
|
||||
loadNextPage,
|
||||
reset
|
||||
} = useLazyPagination(filteredTemplates, {
|
||||
itemsPerPage: 12
|
||||
})
|
||||
|
||||
// Final templates to display
|
||||
const displayTemplates = computed(() => {
|
||||
return shouldUsePagination.value
|
||||
? paginatedTemplates.value
|
||||
: filteredTemplates.value
|
||||
})
|
||||
// Intersection observer for auto-loading (only when not searching)
|
||||
useIntersectionObserver(
|
||||
loadTrigger,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (
|
||||
entry?.isIntersecting &&
|
||||
shouldUsePagination.value &&
|
||||
hasMoreTemplates.value &&
|
||||
!isLoadingMore.value
|
||||
) {
|
||||
void loadNextPage()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '200px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
watch([() => templates, searchQuery], () => {
|
||||
reset()
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('applies clip-path style to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlay = wrapper.findAll('img')[1]
|
||||
expect(overlay.attributes('style')).toContain('clip-path')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageStyle = overlayLazyImage.props('imageStyle')
|
||||
expect(imageStyle.clipPath).toContain('inset')
|
||||
})
|
||||
|
||||
it('renders slider divider', () => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<img
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
:image-class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
:style="{
|
||||
:image-style="{
|
||||
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
|
||||
}"
|
||||
/>
|
||||
@@ -36,6 +36,7 @@
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DefaultThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(DefaultThumbnail, {
|
||||
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('renders image with correct src and alt', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('/test-image.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test Image')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('src')).toBe('/test-image.jpg')
|
||||
expect(lazyImage.props('alt')).toBe('Test Image')
|
||||
})
|
||||
|
||||
it('applies scale transform when hovered', () => {
|
||||
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
|
||||
isHovered: true,
|
||||
hoverZoom: 10
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toContain('scale(1.1)')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
|
||||
})
|
||||
|
||||
it('does not apply scale transform when not hovered', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isHovered: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toBeUndefined()
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
expect(lazyImage.props('imageStyle')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies video styling for video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('w-full')
|
||||
expect(img.classes()).toContain('h-full')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('w-full')
|
||||
expect(classString).toContain('h-full')
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('applies image styling for non-video type', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isVideo: false
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('max-w-full')
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('max-w-full')
|
||||
expect(classString).toContain('object-contain')
|
||||
})
|
||||
|
||||
it('applies correct styling for webp images', () => {
|
||||
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
|
||||
src: '/test-video.webp',
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('image is not draggable', () => {
|
||||
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
|
||||
|
||||
it('applies transition classes', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('transform-gpu')
|
||||
expect(img.classes()).toContain('transition-transform')
|
||||
expect(img.classes()).toContain('duration-300')
|
||||
expect(img.classes()).toContain('ease-out')
|
||||
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
|
||||
const imageClass = lazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transform-gpu')
|
||||
expect(classString).toContain('transition-transform')
|
||||
expect(classString).toContain('duration-300')
|
||||
expect(classString).toContain('ease-out')
|
||||
})
|
||||
|
||||
it('passes correct props to BaseThumbnail', () => {
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
|
||||
<div class="overflow-hidden w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
:class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<LazyImage
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:image-class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:image-style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
/>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { src, isVideo } = defineProps<{
|
||||
|
||||
@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('HoverDissolveThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(HoverDissolveThumbnail, {
|
||||
@@ -27,31 +36,39 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('renders both base and overlay images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe('/base-image.jpg')
|
||||
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages.length).toBe(2)
|
||||
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
|
||||
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
|
||||
})
|
||||
|
||||
it('applies correct alt text to both images', () => {
|
||||
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
|
||||
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
|
||||
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
|
||||
})
|
||||
|
||||
it('makes overlay image visible when hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-100')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-0')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-100')
|
||||
expect(classString).not.toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('makes overlay image hidden when not hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('opacity-0')
|
||||
expect(overlayImage.classes()).not.toContain('opacity-100')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('opacity-0')
|
||||
expect(classString).not.toContain('opacity-100')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
@@ -62,21 +79,33 @@ describe('HoverDissolveThumbnail', () => {
|
||||
|
||||
it('applies transition classes to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlayImage = wrapper.findAll('img')[1]
|
||||
expect(overlayImage.classes()).toContain('transition-opacity')
|
||||
expect(overlayImage.classes()).toContain('duration-300')
|
||||
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
|
||||
const imageClass = overlayLazyImage.props('imageClass')
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('transition-opacity')
|
||||
expect(classString).toContain('duration-300')
|
||||
})
|
||||
|
||||
it('applies correct positioning to both images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
|
||||
|
||||
// Check base image
|
||||
expect(images[0].classes()).toContain('absolute')
|
||||
expect(images[0].classes()).toContain('inset-0')
|
||||
const baseImageClass = lazyImages[0].props('imageClass')
|
||||
const baseClassString = Array.isArray(baseImageClass)
|
||||
? baseImageClass.join(' ')
|
||||
: baseImageClass
|
||||
expect(baseClassString).toContain('absolute')
|
||||
expect(baseClassString).toContain('inset-0')
|
||||
|
||||
// Check overlay image
|
||||
expect(images[1].classes()).toContain('absolute')
|
||||
expect(images[1].classes()).toContain('inset-0')
|
||||
const overlayImageClass = lazyImages[1].props('imageClass')
|
||||
const overlayClassString = Array.isArray(overlayImageClass)
|
||||
? overlayImageClass.join(' ')
|
||||
: overlayImageClass
|
||||
expect(overlayClassString).toContain('absolute')
|
||||
expect(overlayClassString).toContain('inset-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<img
|
||||
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 transition-opacity duration-300"
|
||||
:class="[
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain',
|
||||
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
|
||||
]"
|
||||
:image-class="overlayImageClass"
|
||||
/>
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
|
||||
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
|
||||
baseImageSrc: string
|
||||
overlayImageSrc: string
|
||||
alt: string
|
||||
@@ -44,4 +30,17 @@ const isVideoType =
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
const baseImageClass = computed(() => {
|
||||
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
|
||||
})
|
||||
|
||||
const overlayImageClass = computed(() => {
|
||||
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
|
||||
const sizeClasses = isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
|
||||
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
||||
<span
|
||||
v-tooltip.bottom="{
|
||||
value: workflowOption.workflow.key,
|
||||
class: 'workflow-tab-tooltip',
|
||||
showDelay: 512
|
||||
}"
|
||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
||||
>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="flex p-2 gap-2 workflow-tab"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="workflow-label text-sm max-w-[150px] truncate inline-block">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
@@ -22,23 +22,33 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkflowTabPopover
|
||||
ref="popoverRef"
|
||||
:workflow-filename="workflowOption.workflow.filename"
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
interface WorkflowOption {
|
||||
value: string
|
||||
workflow: ComfyWorkflow
|
||||
@@ -55,6 +65,8 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
|
||||
// Use computed refs to cache autosave settings
|
||||
const autoSaveSetting = computed(() =>
|
||||
@@ -90,6 +102,27 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
popoverRef.value?.hidePopover()
|
||||
}
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
@@ -135,6 +168,10 @@ usePragmaticDroppable(tabGetter, {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
popoverRef.value?.hidePopover()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
229
src/components/topbar/WorkflowTabPopover.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div
|
||||
ref="positionRef"
|
||||
class="absolute left-1/2 -translate-x-1/2"
|
||||
:class="positions.positioner"
|
||||
></div>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'workflow-popover-fade fit-content ' + positions.root,
|
||||
'data-popover-id': id,
|
||||
style: {
|
||||
transform: positions.active
|
||||
}
|
||||
}
|
||||
}"
|
||||
@mouseenter="cancelHidePopover"
|
||||
@mouseleave="hidePopover"
|
||||
>
|
||||
<div class="workflow-preview-content">
|
||||
<div
|
||||
v-if="thumbnailUrl && !isActiveTab"
|
||||
class="workflow-preview-thumbnail relative"
|
||||
>
|
||||
<img
|
||||
:src="thumbnailUrl"
|
||||
class="block h-[200px] object-cover rounded-lg p-2"
|
||||
:style="{ width: `${POPOVER_WIDTH}px` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="workflow-preview-footer">
|
||||
<span class="workflow-preview-name">{{ workflowFilename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, nextTick, ref, toRefs, useId } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const POPOVER_WIDTH = 250
|
||||
|
||||
interface Props {
|
||||
workflowFilename: string
|
||||
thumbnailUrl?: string
|
||||
isActiveTab: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { thumbnailUrl, isActiveTab } = toRefs(props)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const positions = computed<{
|
||||
positioner: string
|
||||
root?: string
|
||||
active?: string
|
||||
}>(() => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar' &&
|
||||
settingStore.get('Comfy.UseNewMenu') === 'Bottom'
|
||||
) {
|
||||
return {
|
||||
positioner: 'top-0',
|
||||
root: 'p-popover-flipped',
|
||||
active: isActiveTab.value ? 'translateY(-100%)' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positioner: 'bottom-0'
|
||||
}
|
||||
})
|
||||
|
||||
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const positionRef = ref<HTMLElement | null>(null)
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const id = useId()
|
||||
|
||||
const showPopover = (event: Event) => {
|
||||
// Clear any existing timeouts
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
if (showTimeout) {
|
||||
clearTimeout(showTimeout)
|
||||
showTimeout = null
|
||||
}
|
||||
|
||||
// Show popover after a short delay
|
||||
showTimeout = setTimeout(async () => {
|
||||
if (popoverRef.value && positionRef.value) {
|
||||
popoverRef.value.show(event, positionRef.value)
|
||||
await nextTick()
|
||||
// PrimeVue has a bug where when the tabs are scrolled, it positions the element incorrectly
|
||||
// Manually set the position to the middle of the tab and prevent it from going off the left/right edge
|
||||
const el = document.querySelector(
|
||||
`.workflow-popover-fade[data-popover-id="${id}"]`
|
||||
) as HTMLElement
|
||||
if (el) {
|
||||
const middle = positionRef.value!.getBoundingClientRect().left
|
||||
const popoverWidth = el.getBoundingClientRect().width
|
||||
const halfWidth = popoverWidth / 2
|
||||
let pos = middle - halfWidth
|
||||
let shift = 0
|
||||
|
||||
// Calculate shift when clamping is needed
|
||||
if (pos < 0) {
|
||||
shift = pos - 8 // Negative shift to move arrow left
|
||||
pos = 8
|
||||
} else if (pos + popoverWidth > window.innerWidth) {
|
||||
const newPos = window.innerWidth - popoverWidth - 16
|
||||
shift = pos - newPos // Positive shift to move arrow right
|
||||
pos = newPos
|
||||
}
|
||||
|
||||
if (shift + halfWidth < 0) {
|
||||
shift = -halfWidth + 24
|
||||
}
|
||||
|
||||
el.style.left = `${pos}px`
|
||||
el.style.setProperty('--shift', `${shift}px`)
|
||||
}
|
||||
}
|
||||
}, 200) // 200ms delay before showing
|
||||
}
|
||||
|
||||
const cancelHidePopover = () => {
|
||||
// Temporarily disable this functionality until we need the popover to be interactive:
|
||||
/*
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
hideTimeout = null
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
const hidePopover = () => {
|
||||
// Clear show timeout if mouse leaves before popover appears
|
||||
if (showTimeout) {
|
||||
clearTimeout(showTimeout)
|
||||
showTimeout = null
|
||||
}
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.hide()
|
||||
}
|
||||
}, 100) // Minimal delay to allow moving to popover
|
||||
}
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showPopover,
|
||||
hidePopover,
|
||||
togglePopover
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-preview-content {
|
||||
@apply flex flex-col rounded-xl overflow-hidden;
|
||||
max-width: var(--popover-width);
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail {
|
||||
@apply relative p-2;
|
||||
}
|
||||
|
||||
.workflow-preview-thumbnail img {
|
||||
@apply shadow-md;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--comfy-menu-secondary-bg) 70%,
|
||||
black
|
||||
);
|
||||
}
|
||||
|
||||
.dark-theme .workflow-preview-thumbnail img {
|
||||
@apply shadow-lg;
|
||||
}
|
||||
|
||||
.workflow-preview-footer {
|
||||
@apply pt-1 pb-2 px-3;
|
||||
}
|
||||
|
||||
.workflow-preview-name {
|
||||
@apply block text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.workflow-popover-fade {
|
||||
--p-popover-background: transparent;
|
||||
--p-popover-content-padding: 0;
|
||||
@apply bg-transparent rounded-xl shadow-lg;
|
||||
transition: opacity 0.15s ease-out !important;
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover-flipped {
|
||||
@apply -translate-y-full;
|
||||
}
|
||||
|
||||
.dark-theme .workflow-popover-fade {
|
||||
@apply shadow-2xl;
|
||||
}
|
||||
|
||||
.workflow-popover-fade.p-popover:after,
|
||||
.workflow-popover-fade.p-popover:before {
|
||||
--p-popover-border-color: var(--comfy-menu-secondary-bg);
|
||||
left: 50%;
|
||||
transform: translateX(calc(-50% + var(--shift)));
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
78
src/composables/bottomPanelTabs/useCommandSubcategories.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
import { type ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
export type SubcategoryRule = {
|
||||
pattern: string | RegExp
|
||||
subcategory: string
|
||||
}
|
||||
|
||||
export type SubcategoryConfig = {
|
||||
defaultSubcategory: string
|
||||
rules: SubcategoryRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for grouping commands by subcategory based on configurable rules
|
||||
*/
|
||||
export function useCommandSubcategories(
|
||||
commands: ComputedRef<ComfyCommandImpl[]>,
|
||||
config: SubcategoryConfig
|
||||
) {
|
||||
const subcategories = computed(() => {
|
||||
const result: Record<string, ComfyCommandImpl[]> = {}
|
||||
|
||||
for (const command of commands.value) {
|
||||
let subcategory = config.defaultSubcategory
|
||||
|
||||
// Find the first matching rule
|
||||
for (const rule of config.rules) {
|
||||
const matches =
|
||||
typeof rule.pattern === 'string'
|
||||
? command.id.includes(rule.pattern)
|
||||
: rule.pattern.test(command.id)
|
||||
|
||||
if (matches) {
|
||||
subcategory = rule.subcategory
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!result[subcategory]) {
|
||||
result[subcategory] = []
|
||||
}
|
||||
result[subcategory].push(command)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
subcategories
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for view controls subcategories
|
||||
*/
|
||||
export const VIEW_CONTROLS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'view',
|
||||
rules: [
|
||||
{ pattern: 'Zoom', subcategory: 'view' },
|
||||
{ pattern: 'Fit', subcategory: 'view' },
|
||||
{ pattern: 'Panel', subcategory: 'panel-controls' },
|
||||
{ pattern: 'Sidebar', subcategory: 'panel-controls' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined configuration for essentials subcategories
|
||||
*/
|
||||
export const ESSENTIALS_CONFIG: SubcategoryConfig = {
|
||||
defaultSubcategory: 'workflow',
|
||||
rules: [
|
||||
{ pattern: 'Workflow', subcategory: 'workflow' },
|
||||
{ pattern: 'Node', subcategory: 'node' },
|
||||
{ pattern: 'Queue', subcategory: 'queue' }
|
||||
]
|
||||
}
|
||||
27
src/composables/bottomPanelTabs/useShortcutsTab.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useShortcutsTab = (): BottomPanelExtension[] => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'shortcuts-essentials',
|
||||
title: t('shortcuts.essentials'),
|
||||
component: markRaw(EssentialsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts-view-controls',
|
||||
title: t('shortcuts.viewControls'),
|
||||
component: markRaw(ViewControlsPanel),
|
||||
type: 'vue',
|
||||
targetPanel: 'shortcuts'
|
||||
}
|
||||
]
|
||||
}
|
||||
80
src/composables/element/useRetriggerableAnimation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import type { Ref, WatchSource } from 'vue'
|
||||
|
||||
/**
|
||||
* A composable that manages retriggerable CSS animations.
|
||||
* Provides a boolean ref that can be toggled to restart CSS animations.
|
||||
*
|
||||
* @param trigger - Optional reactive source that triggers the animation when it changes
|
||||
* @param options - Configuration options
|
||||
* @returns An object containing the animation state ref
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <div :class="{ 'animate-slide-up': shouldAnimate }">
|
||||
* Content
|
||||
* </div>
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const { shouldAnimate } = useRetriggerableAnimation(someReactiveTrigger)
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useRetriggerableAnimation<T = any>(
|
||||
trigger?: WatchSource<T> | Ref<T>,
|
||||
options: {
|
||||
animateOnMount?: boolean
|
||||
animationDelay?: number
|
||||
} = {}
|
||||
) {
|
||||
const { animateOnMount = true, animationDelay = 0 } = options
|
||||
|
||||
const shouldAnimate = ref(false)
|
||||
|
||||
/**
|
||||
* Retriggers the animation by removing and re-adding the animation class
|
||||
*/
|
||||
const retriggerAnimation = () => {
|
||||
// Remove animation class
|
||||
shouldAnimate.value = false
|
||||
// Force browser reflow to ensure the class removal is processed
|
||||
void document.body.offsetHeight
|
||||
// Re-add animation class in the next frame
|
||||
requestAnimationFrame(() => {
|
||||
if (animationDelay > 0) {
|
||||
setTimeout(() => {
|
||||
shouldAnimate.value = true
|
||||
}, animationDelay)
|
||||
} else {
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger animation on mount if requested
|
||||
if (animateOnMount) {
|
||||
onMounted(() => {
|
||||
if (animationDelay > 0) {
|
||||
setTimeout(() => {
|
||||
shouldAnimate.value = true
|
||||
}, animationDelay)
|
||||
} else {
|
||||
shouldAnimate.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for trigger changes to retrigger animation
|
||||
if (trigger) {
|
||||
watch(trigger, () => {
|
||||
retriggerAnimation()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
shouldAnimate,
|
||||
retriggerAnimation
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -70,6 +73,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
category: 'essentials' as const,
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
@@ -77,6 +81,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Open Workflow',
|
||||
menubarLabel: 'Open',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
app.ui.loadFile()
|
||||
}
|
||||
@@ -92,6 +97,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -104,6 +110,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
@@ -116,6 +123,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-download',
|
||||
label: 'Export Workflow',
|
||||
menubarLabel: 'Export',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await workflowService.exportWorkflow('workflow', 'workflow')
|
||||
}
|
||||
@@ -133,6 +141,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Undo',
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
@@ -141,6 +150,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Redo',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
@@ -149,6 +159,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearWorkflow',
|
||||
icon: 'pi pi-trash',
|
||||
label: 'Clear Workflow',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const settingStore = useSettingStore()
|
||||
if (
|
||||
@@ -190,6 +201,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.RefreshNodeDefinitions',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Refresh Node Definitions',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await app.refreshComboInNodes()
|
||||
}
|
||||
@@ -198,6 +210,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Interrupt',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Interrupt',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
toastStore.add({
|
||||
@@ -212,6 +225,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ClearPendingTasks',
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Clear Pending Tasks',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await useQueueStore().clear(['queue'])
|
||||
toastStore.add({
|
||||
@@ -234,6 +248,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomIn',
|
||||
icon: 'pi pi-plus',
|
||||
label: 'Zoom In',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -247,6 +262,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ZoomOut',
|
||||
icon: 'pi pi-minus',
|
||||
label: 'Zoom Out',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
@@ -260,6 +276,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
@@ -325,6 +342,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(0, batchCount)
|
||||
@@ -335,6 +353,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-play',
|
||||
label: 'Queue Prompt (Front)',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
@@ -371,6 +390,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-cog',
|
||||
label: 'Show Settings Dialog',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
@@ -380,6 +400,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Group Selected Nodes',
|
||||
versionAdded: '1.3.7',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) {
|
||||
@@ -423,6 +444,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-volume-off',
|
||||
label: 'Mute/Unmute Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -433,6 +455,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-shield',
|
||||
label: 'Bypass/Unbypass Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
@@ -443,6 +466,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-pin',
|
||||
label: 'Pin/Unpin Selected Nodes',
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
getSelectedNodes().forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
@@ -516,8 +540,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useBottomPanelStore().toggleBottomPanel()
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -525,6 +550,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
@@ -750,6 +776,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
@@ -768,6 +795,16 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
label: 'Show Keybindings Dialog',
|
||||
versionAdded: '1.24.1',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
60
src/composables/useIntersectionObserver.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
export interface UseIntersectionObserverOptions
|
||||
extends IntersectionObserverInit {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
target: Ref<Element | null>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
) {
|
||||
const { immediate = true, ...observerOptions } = options
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'IntersectionObserver' in window
|
||||
const isIntersecting = ref(false)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
const observe = () => {
|
||||
cleanup()
|
||||
|
||||
if (!isSupported || !target.value) return
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
|
||||
callback(entries, observer!)
|
||||
}, observerOptions)
|
||||
|
||||
observer.observe(target.value)
|
||||
}
|
||||
|
||||
const unobserve = () => {
|
||||
if (observer && target.value) {
|
||||
observer.unobserve(target.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
watch(target, observe, { immediate: true, flush: 'post' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isIntersecting,
|
||||
observe,
|
||||
unobserve,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
107
src/composables/useLazyPagination.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
export interface LazyPaginationOptions {
|
||||
itemsPerPage?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
export function useLazyPagination<T>(
|
||||
items: Ref<T[]> | T[],
|
||||
options: LazyPaginationOptions = {}
|
||||
) {
|
||||
const { itemsPerPage = 12, initialPage = 1 } = options
|
||||
|
||||
const currentPage = ref(initialPage)
|
||||
const isLoading = ref(false)
|
||||
const loadedPages = shallowRef(new Set<number>([]))
|
||||
|
||||
// Get reactive items array
|
||||
const itemsArray = computed(() => {
|
||||
const itemData = 'value' in items ? items.value : items
|
||||
return Array.isArray(itemData) ? itemData : []
|
||||
})
|
||||
|
||||
// Simulate pagination by slicing the items
|
||||
const paginatedItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const loadedPageNumbers = Array.from(loadedPages.value).sort(
|
||||
(a, b) => a - b
|
||||
)
|
||||
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
|
||||
const endIndex = maxLoadedPage * itemsPerPage
|
||||
return itemData.slice(0, endIndex)
|
||||
})
|
||||
|
||||
const hasMoreItems = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
|
||||
return maxLoadedPage * itemsPerPage < itemData.length
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.ceil(itemData.length / itemsPerPage)
|
||||
})
|
||||
|
||||
const loadNextPage = async () => {
|
||||
if (isLoading.value || !hasMoreItems.value) return
|
||||
|
||||
isLoading.value = true
|
||||
const loadedPagesArray = Array.from(loadedPages.value)
|
||||
const nextPage = Math.max(...loadedPagesArray, 0) + 1
|
||||
|
||||
// Simulate network delay
|
||||
// await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
|
||||
const newLoadedPages = new Set(loadedPages.value)
|
||||
newLoadedPages.add(nextPage)
|
||||
loadedPages.value = newLoadedPages
|
||||
currentPage.value = nextPage
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// Initialize with first page
|
||||
watch(
|
||||
() => itemsArray.value.length,
|
||||
(length) => {
|
||||
if (length > 0 && loadedPages.value.size === 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = initialPage
|
||||
loadedPages.value = new Set([])
|
||||
isLoading.value = false
|
||||
|
||||
// Immediately load first page if we have items
|
||||
const itemData = itemsArray.value
|
||||
if (itemData.length > 0) {
|
||||
loadedPages.value = new Set([1])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
paginatedItems,
|
||||
isLoading,
|
||||
hasMoreItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loadNextPage,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -434,13 +434,14 @@ export function useMinimap() {
|
||||
const { startSync: startViewportSync, stopSync: stopViewportSync } =
|
||||
useCanvasTransformSync(updateViewport, { autoStart: false })
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Pointer event handlers for touch screen support
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
isDragging.value = true
|
||||
updateContainerRect()
|
||||
handleMouseMove(e)
|
||||
handlePointerMove(e)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging.value || !canvasRef.value || !canvas.value) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
@@ -455,7 +456,7 @@ export function useMinimap() {
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handlePointerUp = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
@@ -695,9 +696,10 @@ export function useMinimap() {
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
renderMinimap,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
setMinimapRef
|
||||
}
|
||||
|
||||
56
src/composables/useTemplateFiltering.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
export interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const searchQuery = ref('')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.sourceModule
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredTemplates,
|
||||
filteredCount,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
108
src/composables/useWorkflowThumbnail.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
import { useMinimap } from './useMinimap'
|
||||
|
||||
// Store thumbnails for each workflow
|
||||
const workflowThumbnails = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Shared minimap instance
|
||||
let minimap: ReturnType<typeof useMinimap> | null = null
|
||||
|
||||
export const useWorkflowThumbnail = () => {
|
||||
/**
|
||||
* Capture a thumbnail of the canvas
|
||||
*/
|
||||
const createMinimapPreview = (): Promise<string | null> => {
|
||||
try {
|
||||
if (!minimap) {
|
||||
minimap = useMinimap()
|
||||
minimap.canvasRef.value = document.createElement('canvas')
|
||||
minimap.canvasRef.value.width = minimap.width
|
||||
minimap.canvasRef.value.height = minimap.height
|
||||
}
|
||||
minimap.renderMinimap()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
minimap!.canvasRef.value!.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to capture canvas thumbnail:', error)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a thumbnail for a workflow
|
||||
*/
|
||||
const storeThumbnail = async (workflow: ComfyWorkflow) => {
|
||||
const thumbnail = await createMinimapPreview()
|
||||
if (thumbnail) {
|
||||
// Clean up existing thumbnail if it exists
|
||||
const existingThumbnail = workflowThumbnails.value.get(workflow.key)
|
||||
if (existingThumbnail) {
|
||||
URL.revokeObjectURL(existingThumbnail)
|
||||
}
|
||||
workflowThumbnails.value.set(workflow.key, thumbnail)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a thumbnail for a workflow
|
||||
*/
|
||||
const getThumbnail = (workflowKey: string): string | undefined => {
|
||||
return workflowThumbnails.value.get(workflowKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a thumbnail for a workflow
|
||||
*/
|
||||
const clearThumbnail = (workflowKey: string) => {
|
||||
const thumbnail = workflowThumbnails.value.get(workflowKey)
|
||||
if (thumbnail) {
|
||||
URL.revokeObjectURL(thumbnail)
|
||||
}
|
||||
workflowThumbnails.value.delete(workflowKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all thumbnails
|
||||
*/
|
||||
const clearAllThumbnails = () => {
|
||||
for (const thumbnail of workflowThumbnails.value.values()) {
|
||||
URL.revokeObjectURL(thumbnail)
|
||||
}
|
||||
workflowThumbnails.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a thumbnail from one workflow key to another (useful for workflow renaming)
|
||||
*/
|
||||
const moveWorkflowThumbnail = (oldKey: string, newKey: string) => {
|
||||
// Don't do anything if moving to the same key
|
||||
if (oldKey === newKey) return
|
||||
|
||||
const thumbnail = workflowThumbnails.value.get(oldKey)
|
||||
if (thumbnail) {
|
||||
workflowThumbnails.value.set(newKey, thumbnail)
|
||||
workflowThumbnails.value.delete(oldKey)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createMinimapPreview,
|
||||
storeThumbnail,
|
||||
getThumbnail,
|
||||
clearThumbnail,
|
||||
clearAllThumbnails,
|
||||
moveWorkflowThumbnail,
|
||||
workflowThumbnails
|
||||
}
|
||||
}
|
||||
@@ -182,5 +182,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'k'
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanel.Shortcuts'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { Point, Rect } from './interfaces'
|
||||
import { LGraphCanvas, clamp } from './litegraph'
|
||||
import { LGraphCanvas } from './litegraph'
|
||||
import { distance } from './measure'
|
||||
|
||||
// used by some widgets to render a curve editor
|
||||
|
||||
@@ -1551,10 +1551,12 @@ export class LGraph
|
||||
|
||||
// Create subgraph node object
|
||||
const subgraphNode = LiteGraph.createNode(subgraph.id, subgraph.name, {
|
||||
inputs: structuredClone(inputs),
|
||||
outputs: structuredClone(outputs)
|
||||
})
|
||||
if (!subgraphNode) throw new Error('Failed to create subgraph node')
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
Object.assign(subgraphNode.inputs[i], inputs[i])
|
||||
}
|
||||
|
||||
// Resize to inputs/outputs
|
||||
subgraphNode.setSize(subgraphNode.computeSize())
|
||||
@@ -1656,6 +1658,8 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
subgraphNode._setConcreteSlots()
|
||||
subgraphNode.arrange()
|
||||
return { subgraph, node: subgraphNode as SubgraphNode }
|
||||
}
|
||||
|
||||
|
||||
@@ -2299,6 +2299,8 @@ export class LGraphCanvas
|
||||
|
||||
const node_data = node.clone()?.serialize()
|
||||
if (node_data?.type != null) {
|
||||
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
|
||||
node_data.type = newType
|
||||
const cloned = LiteGraph.createNode(newType)
|
||||
if (cloned) {
|
||||
cloned.configure(node_data)
|
||||
@@ -2384,7 +2386,7 @@ export class LGraphCanvas
|
||||
// Set the width of the line for isPointInStroke checks
|
||||
const { lineWidth } = this.ctx
|
||||
this.ctx.lineWidth = this.connections_width + 7
|
||||
const dpi = window?.devicePixelRatio || 1
|
||||
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
@@ -4130,6 +4132,7 @@ export class LGraphCanvas
|
||||
const selected = this.selectedItems
|
||||
if (!selected.size) return
|
||||
|
||||
const initialSelectionSize = selected.size
|
||||
let wasSelected: Positionable | undefined
|
||||
for (const sel of selected) {
|
||||
if (sel === keepSelected) {
|
||||
@@ -4170,8 +4173,12 @@ export class LGraphCanvas
|
||||
}
|
||||
}
|
||||
|
||||
this.state.selectionChanged = true
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
// Only set selectionChanged if selection actually changed
|
||||
const finalSelectionSize = selected.size
|
||||
if (initialSelectionSize !== finalSelectionSize) {
|
||||
this.state.selectionChanged = true
|
||||
this.onSelectionChange?.(this.selected_nodes)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated See {@link LGraphCanvas.deselectAll} */
|
||||
@@ -8063,18 +8070,6 @@ export class LGraphCanvas
|
||||
options = node.getMenuOptions(this)
|
||||
} else {
|
||||
options = [
|
||||
{
|
||||
content: 'Inputs',
|
||||
has_submenu: true,
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
content: 'Outputs',
|
||||
has_submenu: true,
|
||||
disabled: true,
|
||||
callback: LGraphCanvas.showMenuNodeOptionalOutputs
|
||||
},
|
||||
null,
|
||||
{
|
||||
content: 'Convert to Subgraph 🆕',
|
||||
callback: () => {
|
||||
@@ -8242,14 +8237,16 @@ export class LGraphCanvas
|
||||
'Both in put and output slots were null when processing context menu.'
|
||||
)
|
||||
|
||||
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||
menu_info.push({ content: 'Rename Slot', slot })
|
||||
}
|
||||
|
||||
if (_slot.removable) {
|
||||
menu_info.push(null)
|
||||
menu_info.push(
|
||||
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
|
||||
)
|
||||
}
|
||||
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||
menu_info.push({ content: 'Rename Slot', slot })
|
||||
}
|
||||
|
||||
if (node.getExtraSlotMenuOptions) {
|
||||
menu_info.push(...node.getExtraSlotMenuOptions(slot))
|
||||
|
||||
@@ -795,7 +795,10 @@ export class LinkConnector {
|
||||
*/
|
||||
disconnectLinks(): void {
|
||||
for (const link of this.renderLinks) {
|
||||
if (link instanceof MovingLinkBase) {
|
||||
if (
|
||||
link instanceof MovingLinkBase ||
|
||||
link instanceof ToInputFromIoNodeLink
|
||||
) {
|
||||
link.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,4 +135,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
connectToRerouteOutput() {
|
||||
throw new Error('ToInputRenderLink cannot connect to an output.')
|
||||
}
|
||||
disconnect(): boolean {
|
||||
if (!this.existingLink) return false
|
||||
this.existingLink.disconnect(this.network, 'input')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type {
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Basic width and height, with min/max constraints.
|
||||
|
||||
@@ -134,7 +134,7 @@ export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
|
||||
export { LGraphGroup } from './LGraphGroup'
|
||||
export { LGraphNode, type NodeId } from './LGraphNode'
|
||||
export { type LinkId, LLink } from './LLink'
|
||||
export { clamp, createBounds } from './measure'
|
||||
export { createBounds } from './measure'
|
||||
export { Reroute, type RerouteId } from './Reroute'
|
||||
export {
|
||||
type ExecutableLGraphNode,
|
||||
|
||||
@@ -450,7 +450,3 @@ export function alignOutsideContainer(
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return value < min ? min : value > max ? max : value
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
CallbackReturn,
|
||||
ISlotType
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { Subgraph } from './Subgraph'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
@@ -263,13 +263,8 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
const { inputs } = this
|
||||
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [slot, ...parentInputIndexes]
|
||||
const matchingIndex = indexes.find((i) => inputs[i]?.type === type)
|
||||
const matchingIndex = this.#getBypassSlotIndex(slot, type)
|
||||
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) {
|
||||
@@ -326,6 +321,44 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the input slot on this node that matches the given output {@link slot} index.
|
||||
* Used when bypassing nodes.
|
||||
* @param slot The output slot index on this node
|
||||
* @param type The type of the final target input (so type list matches are accurate)
|
||||
* @returns The index of the input slot on this node, otherwise `undefined`.
|
||||
*/
|
||||
#getBypassSlotIndex(slot: number, type: ISlotType) {
|
||||
const { inputs } = this
|
||||
const oppositeInput = inputs[slot]
|
||||
const outputType = this.node.outputs[slot].type
|
||||
|
||||
// Any type short circuit - match slot ID, fallback to first slot
|
||||
if (type === '*' || type === '') {
|
||||
return inputs.length > slot ? slot : 0
|
||||
}
|
||||
|
||||
// Prefer input with the same slot ID
|
||||
if (
|
||||
oppositeInput &&
|
||||
LiteGraph.isValidConnection(oppositeInput.type, outputType) &&
|
||||
LiteGraph.isValidConnection(oppositeInput.type, type)
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
|
||||
// Find first matching slot - prefer exact type
|
||||
return (
|
||||
// Preserve legacy behaviour; use exact match first.
|
||||
inputs.findIndex((input) => input.type === type) ??
|
||||
inputs.findIndex(
|
||||
(input) =>
|
||||
LiteGraph.isValidConnection(input.type, outputType) &&
|
||||
LiteGraph.isValidConnection(input.type, type)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the link inside a subgraph node, from the subgraph IO node to the node inside the subgraph.
|
||||
* @param slot The slot index of the output on the subgraph node.
|
||||
|
||||
@@ -170,6 +170,21 @@ export abstract class SubgraphIONodeBase<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles double-click on an IO slot to rename it.
|
||||
* @param slot The slot that was double-clicked.
|
||||
* @param event The event that triggered the double-click.
|
||||
*/
|
||||
protected handleSlotDoubleClick(
|
||||
slot: TSlot,
|
||||
event: CanvasPointerEvent
|
||||
): void {
|
||||
// Only allow renaming non-empty slots
|
||||
if (slot !== this.emptySlot) {
|
||||
this.#promptForSlotRename(slot, event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the context menu for an IO slot.
|
||||
* @param slot The slot to show the context menu for.
|
||||
@@ -239,16 +254,7 @@ export abstract class SubgraphIONodeBase<
|
||||
// Rename the slot
|
||||
case 'rename':
|
||||
if (slot !== this.emptySlot) {
|
||||
this.subgraph.canvasAction((c) =>
|
||||
c.prompt(
|
||||
'Slot name',
|
||||
slot.name,
|
||||
(newName: string) => {
|
||||
if (newName) this.renameSlot(slot, newName)
|
||||
},
|
||||
event
|
||||
)
|
||||
)
|
||||
this.#promptForSlotRename(slot, event)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -256,6 +262,24 @@ export abstract class SubgraphIONodeBase<
|
||||
this.subgraph.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to rename a slot.
|
||||
* @param slot The slot to rename.
|
||||
* @param event The event that triggered the rename.
|
||||
*/
|
||||
#promptForSlotRename(slot: TSlot, event: CanvasPointerEvent): void {
|
||||
this.subgraph.canvasAction((c) =>
|
||||
c.prompt(
|
||||
'Slot name',
|
||||
slot.displayName,
|
||||
(newName: string) => {
|
||||
if (newName) this.renameSlot(slot, newName)
|
||||
},
|
||||
event
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Arrange the slots in this node. */
|
||||
arrange(): void {
|
||||
const { minWidth, roundedRadius } = SubgraphIONodeBase
|
||||
|
||||
@@ -4,7 +4,6 @@ import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot,
|
||||
@@ -51,18 +50,17 @@ export class SubgraphInputNode
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
const slotBounds = Rectangle.fromCentre(
|
||||
slot.pos,
|
||||
slot.boundingRect.height
|
||||
)
|
||||
|
||||
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
|
||||
// Check if click is within the full slot area (including label)
|
||||
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphInput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.onDoubleClick = () => {
|
||||
this.handleSlotDoubleClick(slot, e)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
(e) => {
|
||||
const subgraphInput = e.detail.input
|
||||
const { name, type } = subgraphInput
|
||||
if (this.inputs.some((i) => i.name == name)) return
|
||||
const existingInput = this.inputs.find((i) => i.name == name)
|
||||
if (existingInput) {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
|
||||
if (widget) this.#setWidget(subgraphInput, existingInput, widget)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
@@ -118,6 +125,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!input) throw new Error('Subgraph input not found')
|
||||
|
||||
input.label = newName
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
INodeInputSlot,
|
||||
@@ -51,18 +50,17 @@ export class SubgraphOutputNode
|
||||
// Left-click handling for dragging connections
|
||||
if (e.button === 0) {
|
||||
for (const slot of this.allSlots) {
|
||||
const slotBounds = Rectangle.fromCentre(
|
||||
slot.pos,
|
||||
slot.boundingRect.height
|
||||
)
|
||||
|
||||
if (slotBounds.containsXy(e.canvasX, e.canvasY)) {
|
||||
// Check if click is within the full slot area (including label)
|
||||
if (slot.boundingRect.containsXy(e.canvasX, e.canvasY)) {
|
||||
pointer.onDragStart = () => {
|
||||
linkConnector.dragNewFromSubgraphOutput(this.subgraph, this, slot)
|
||||
}
|
||||
pointer.onDragEnd = (eUp) => {
|
||||
linkConnector.dropLinks(this.subgraph, eUp)
|
||||
}
|
||||
pointer.onDoubleClick = () => {
|
||||
this.handleSlotDoubleClick(slot, e)
|
||||
}
|
||||
pointer.finally = () => {
|
||||
linkConnector.reset(true)
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export function omitBy<T extends object>(
|
||||
obj: T,
|
||||
predicate: (value: any) => boolean
|
||||
): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_key, value]) => !predicate(value))
|
||||
) as Partial<T>
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IComboWidget,
|
||||
IStringComboWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { IKnobWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { ISliderWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { clamp } from 'lodash'
|
||||
import { beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
|
||||
import { LGraphCanvas, LiteGraph, clamp } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './testExtensions'
|
||||
|
||||
|
||||
@@ -230,12 +230,21 @@
|
||||
"Workspace_ToggleBottomPanel": {
|
||||
"label": "Toggle Bottom Panel"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "Show Keybindings Dialog"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_command-terminal": {
|
||||
"label": "Toggle Terminal Bottom Panel"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_logs-terminal": {
|
||||
"label": "Toggle Logs Bottom Panel"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "Toggle Essential Bottom Panel"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "Toggle View Controls Bottom Panel"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "Toggle Focus Mode"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"confirmed": "Confirmed",
|
||||
"reset": "Reset",
|
||||
"resetAll": "Reset All",
|
||||
"clearFilters": "Clear Filters",
|
||||
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
|
||||
"customizeFolder": "Customize Folder",
|
||||
"icon": "Icon",
|
||||
@@ -72,6 +73,7 @@
|
||||
"searchWorkflows": "Search Workflows",
|
||||
"searchSettings": "Search Settings",
|
||||
"searchNodes": "Search Nodes",
|
||||
"searchCommands": "Search Commands",
|
||||
"searchModels": "Search Models",
|
||||
"searchKeybindings": "Search Keybindings",
|
||||
"searchExtensions": "Search Extensions",
|
||||
@@ -549,6 +551,8 @@
|
||||
},
|
||||
"templateWorkflows": {
|
||||
"title": "Get Started with a Template",
|
||||
"loadingMore": "Loading more templates...",
|
||||
"searchPlaceholder": "Search templates...",
|
||||
"category": {
|
||||
"ComfyUI Examples": "ComfyUI Examples",
|
||||
"Custom Nodes": "Custom Nodes",
|
||||
@@ -1008,8 +1012,11 @@
|
||||
"Previous Opened Workflow": "Previous Opened Workflow",
|
||||
"Toggle Search Box": "Toggle Search Box",
|
||||
"Toggle Bottom Panel": "Toggle Bottom Panel",
|
||||
"Show Keybindings Dialog": "Show Keybindings Dialog",
|
||||
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
|
||||
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
|
||||
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
|
||||
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
|
||||
"Toggle Focus Mode": "Toggle Focus Mode",
|
||||
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
|
||||
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
|
||||
@@ -1627,5 +1634,19 @@
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"enterNewName": "Enter new name"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
"noKeybinding": "No keybinding",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"subcategories": {
|
||||
"workflow": "Workflow",
|
||||
"node": "Node",
|
||||
"queue": "Queue",
|
||||
"view": "View",
|
||||
"panelControls": "Panel Controls"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,15 @@
|
||||
"Workspace_ToggleBottomPanelTab_logs-terminal": {
|
||||
"label": "Alternar Panel Inferior de Registros"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "Alternar panel inferior esencial"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "Alternar panel inferior de controles de vista"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "Mostrar diálogo de combinaciones de teclas"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "Alternar Modo de Enfoque"
|
||||
},
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Categoría",
|
||||
"choose_file_to_upload": "elige archivo para subir",
|
||||
"clear": "Limpiar",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"close": "Cerrar",
|
||||
"color": "Color",
|
||||
"comingSoon": "Próximamente",
|
||||
@@ -369,6 +370,7 @@
|
||||
"resultsCount": "Encontrados {count} resultados",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"searchCommands": "Buscar comandos",
|
||||
"searchExtensions": "Buscar extensiones",
|
||||
"searchFailedMessage": "No pudimos encontrar ninguna configuración que coincida con tu búsqueda. Intenta ajustar tus términos de búsqueda.",
|
||||
"searchKeybindings": "Buscar combinaciones de teclas",
|
||||
@@ -807,9 +809,11 @@
|
||||
"Restart": "Reiniciar",
|
||||
"Save": "Guardar",
|
||||
"Save As": "Guardar como",
|
||||
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
|
||||
"Show Settings Dialog": "Mostrar diálogo de configuración",
|
||||
"Sign Out": "Cerrar sesión",
|
||||
"Toggle Bottom Panel": "Alternar panel inferior",
|
||||
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
|
||||
"Toggle Focus Mode": "Alternar modo de enfoque",
|
||||
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
|
||||
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
|
||||
@@ -818,6 +822,7 @@
|
||||
"Toggle Search Box": "Alternar caja de búsqueda",
|
||||
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
|
||||
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
|
||||
"Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
|
||||
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
|
||||
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
|
||||
@@ -1144,6 +1149,20 @@
|
||||
"Window": "Ventana",
|
||||
"Workflow": "Flujo de Trabajo"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Esenciales",
|
||||
"keyboardShortcuts": "Atajos de teclado",
|
||||
"manageShortcuts": "Gestionar atajos",
|
||||
"noKeybinding": "Sin asignación de tecla",
|
||||
"subcategories": {
|
||||
"node": "Nodo",
|
||||
"panelControls": "Controles del panel",
|
||||
"queue": "Cola",
|
||||
"view": "Vista",
|
||||
"workflow": "Flujo de trabajo"
|
||||
},
|
||||
"viewControls": "Controles de vista"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Explorar plantillas de ejemplo",
|
||||
"downloads": "Descargas",
|
||||
@@ -1231,6 +1250,8 @@
|
||||
"Video": "Video",
|
||||
"Video API": "API de Video"
|
||||
},
|
||||
"loadingMore": "Cargando más plantillas...",
|
||||
"searchPlaceholder": "Buscar plantillas...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
|
||||
@@ -236,6 +236,15 @@
|
||||
"Workspace_ToggleBottomPanelTab_logs-terminal": {
|
||||
"label": "Basculer le panneau inférieur des journaux"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "Afficher/Masquer le panneau inférieur essentiel"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "Afficher/Masquer le panneau inférieur des contrôles de vue"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "Afficher la boîte de dialogue des raccourcis clavier"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "Basculer le mode focus"
|
||||
},
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "Catégorie",
|
||||
"choose_file_to_upload": "choisissez le fichier à télécharger",
|
||||
"clear": "Effacer",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"close": "Fermer",
|
||||
"color": "Couleur",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
@@ -369,6 +370,7 @@
|
||||
"resultsCount": "{count} Résultats Trouvés",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement",
|
||||
"searchCommands": "Rechercher des commandes",
|
||||
"searchExtensions": "Rechercher des extensions",
|
||||
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
|
||||
"searchKeybindings": "Rechercher des raccourcis clavier",
|
||||
@@ -807,9 +809,11 @@
|
||||
"Restart": "Redémarrer",
|
||||
"Save": "Enregistrer",
|
||||
"Save As": "Enregistrer sous",
|
||||
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
|
||||
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
|
||||
"Sign Out": "Se déconnecter",
|
||||
"Toggle Bottom Panel": "Basculer le panneau inférieur",
|
||||
"Toggle Essential Bottom Panel": "Afficher/Masquer le panneau inférieur essentiel",
|
||||
"Toggle Focus Mode": "Basculer le mode focus",
|
||||
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
|
||||
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
|
||||
@@ -818,6 +822,7 @@
|
||||
"Toggle Search Box": "Basculer la boîte de recherche",
|
||||
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
|
||||
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
|
||||
"Toggle View Controls Bottom Panel": "Afficher/Masquer le panneau inférieur des contrôles de vue",
|
||||
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
|
||||
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
|
||||
@@ -1144,6 +1149,20 @@
|
||||
"Window": "Fenêtre",
|
||||
"Workflow": "Flux de Travail"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "Essentiel",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"manageShortcuts": "Gérer les raccourcis",
|
||||
"noKeybinding": "Aucun raccourci",
|
||||
"subcategories": {
|
||||
"node": "Nœud",
|
||||
"panelControls": "Contrôles du panneau",
|
||||
"queue": "File d’attente",
|
||||
"view": "Affichage",
|
||||
"workflow": "Flux de travail"
|
||||
},
|
||||
"viewControls": "Contrôles d’affichage"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Parcourir les modèles d'exemple",
|
||||
"downloads": "Téléchargements",
|
||||
@@ -1231,6 +1250,8 @@
|
||||
"Video": "Vidéo",
|
||||
"Video API": "API vidéo"
|
||||
},
|
||||
"loadingMore": "Chargement de plus de modèles...",
|
||||
"searchPlaceholder": "Rechercher des modèles...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||
@@ -236,6 +236,15 @@
|
||||
"Workspace_ToggleBottomPanelTab_logs-terminal": {
|
||||
"label": "ログパネル下部の切り替え"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "必須な下部パネルを切り替え"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "表示コントロール下部パネルの切り替え"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "キーバインドダイアログを表示"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "フォーカスモードの切り替え"
|
||||
},
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"category": "カテゴリ",
|
||||
"choose_file_to_upload": "アップロードするファイルを選択",
|
||||
"clear": "クリア",
|
||||
"clearFilters": "フィルターをクリア",
|
||||
"close": "閉じる",
|
||||
"color": "色",
|
||||
"comingSoon": "近日公開",
|
||||
@@ -369,6 +370,7 @@
|
||||
"resultsCount": "{count}件の結果が見つかりました",
|
||||
"save": "保存",
|
||||
"saving": "保存中",
|
||||
"searchCommands": "コマンドを検索",
|
||||
"searchExtensions": "拡張機能を検索",
|
||||
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
|
||||
"searchKeybindings": "キーバインディングを検索",
|
||||
@@ -807,9 +809,11 @@
|
||||
"Restart": "再起動",
|
||||
"Save": "保存",
|
||||
"Save As": "名前を付けて保存",
|
||||
"Show Keybindings Dialog": "キーバインドダイアログを表示",
|
||||
"Show Settings Dialog": "設定ダイアログを表示",
|
||||
"Sign Out": "サインアウト",
|
||||
"Toggle Bottom Panel": "下部パネルの切り替え",
|
||||
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",
|
||||
"Toggle Focus Mode": "フォーカスモードの切り替え",
|
||||
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
|
||||
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
|
||||
@@ -818,6 +822,7 @@
|
||||
"Toggle Search Box": "検索ボックスの切り替え",
|
||||
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
|
||||
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
|
||||
"Toggle View Controls Bottom Panel": "ビューコントロール下部パネルの切り替え",
|
||||
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
|
||||
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
|
||||
@@ -1144,6 +1149,20 @@
|
||||
"Window": "ウィンドウ",
|
||||
"Workflow": "ワークフロー"
|
||||
},
|
||||
"shortcuts": {
|
||||
"essentials": "基本",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"manageShortcuts": "ショートカット管理",
|
||||
"noKeybinding": "キー割り当てなし",
|
||||
"subcategories": {
|
||||
"node": "ノード",
|
||||
"panelControls": "パネルコントロール",
|
||||
"queue": "キュー",
|
||||
"view": "ビュー",
|
||||
"workflow": "ワークフロー"
|
||||
},
|
||||
"viewControls": "表示コントロール"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "サンプルテンプレートを表示",
|
||||
"downloads": "ダウンロード",
|
||||
@@ -1231,6 +1250,8 @@
|
||||
"Video": "ビデオ",
|
||||
"Video API": "動画API"
|
||||
},
|
||||
"loadingMore": "さらにテンプレートを読み込み中...",
|
||||
"searchPlaceholder": "テンプレートを検索...",
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
|
||||