Compare commits
107 Commits
v1.19.8
...
fix/progre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963e87a20c | ||
|
|
cc64dec61a | ||
|
|
7b7ff11902 | ||
|
|
8cc1a87e1b | ||
|
|
d71dfa3c0c | ||
|
|
b7920ffbf6 | ||
|
|
57e02863aa | ||
|
|
c037b2c3a9 | ||
|
|
92c870ccca | ||
|
|
becfe25e1c | ||
|
|
57e96832d7 | ||
|
|
ec190e09c0 | ||
|
|
39f9680c4d | ||
|
|
57b5075c99 | ||
|
|
c2a0dc0825 | ||
|
|
5423db796d | ||
|
|
ad50e361c1 | ||
|
|
a91a056eba | ||
|
|
0bc5229381 | ||
|
|
ffd8465bda | ||
|
|
fc963bea42 | ||
|
|
5166980c9b | ||
|
|
51c0173750 | ||
|
|
60cf8ffcae | ||
|
|
cf8a914398 | ||
|
|
46d964f520 | ||
|
|
8c8f32078a | ||
|
|
e059fe286c | ||
|
|
b2722ac4cc | ||
|
|
7448c57a13 | ||
|
|
6ba5116b4b | ||
|
|
e06efbdd72 | ||
|
|
5f9388cb02 | ||
|
|
bcfc944214 | ||
|
|
65cec4611c | ||
|
|
2f350395e7 | ||
|
|
669dbdbe56 | ||
|
|
c96327e586 | ||
|
|
60b7661022 | ||
|
|
2818916a7f | ||
|
|
e467c40c41 | ||
|
|
58cc1f366d | ||
|
|
1b69216ce0 | ||
|
|
33f46495f8 | ||
|
|
17deca3232 | ||
|
|
4aef8386a7 | ||
|
|
369bd71240 | ||
|
|
681d6d82f0 | ||
|
|
14de9cfa36 | ||
|
|
f08ec0a981 | ||
|
|
356886dc29 | ||
|
|
f96de24a66 | ||
|
|
9d48487af8 | ||
|
|
69b534bf14 | ||
|
|
2acb2ac181 | ||
|
|
37a583e39c | ||
|
|
d8821db2be | ||
|
|
f2c4e567e4 | ||
|
|
fec4c4e928 | ||
|
|
49d32f4809 | ||
|
|
07f0b88e30 | ||
|
|
d92ed22908 | ||
|
|
24c0c2c499 | ||
|
|
774bff2ed6 | ||
|
|
6d87f2b2ff | ||
|
|
20911aa892 | ||
|
|
3a6018589e | ||
|
|
4c92a7142e | ||
|
|
293993e7de | ||
|
|
a7ee3fae05 | ||
|
|
22dc84324e | ||
|
|
e76e9ec61a | ||
|
|
94fde504d0 | ||
|
|
e3ecf90bb3 | ||
|
|
a131f36cf3 | ||
|
|
4cad1a9567 | ||
|
|
47a6c6d595 | ||
|
|
068279ec34 | ||
|
|
2885ebf5e0 | ||
|
|
d4e76ddc45 | ||
|
|
9a5b80a279 | ||
|
|
985dab7e1c | ||
|
|
7f2b8a5321 | ||
|
|
59ce169ec9 | ||
|
|
4294b2c13b | ||
|
|
242c7e2885 | ||
|
|
c1442ec755 | ||
|
|
ebd9c96a28 | ||
|
|
e6d649b596 | ||
|
|
b037ba84e3 | ||
|
|
7c5c47c105 | ||
|
|
b152f67d95 | ||
|
|
be84d81c32 | ||
|
|
a474a094f3 | ||
|
|
bc360eef15 | ||
|
|
a52cc0ebe9 | ||
|
|
b3c6513e7a | ||
|
|
a9bdc70e28 | ||
|
|
58906fa821 | ||
|
|
a17fb04f83 | ||
|
|
5c0ad994d8 | ||
|
|
31be0a04f0 | ||
|
|
d9ab4270d1 | ||
|
|
36bd1f74ca | ||
|
|
7144ec54aa | ||
|
|
b2f144c27b | ||
|
|
014c0022c1 |
@@ -25,3 +25,7 @@ ENABLE_MINIFY=true
|
||||
# templates are served via the normal method from the server's python site
|
||||
# packages.
|
||||
DISABLE_TEMPLATES_PROXY=false
|
||||
|
||||
# If playwright tests are being run via vite dev server, Vue plugins will
|
||||
# invalidate screenshots. When `true`, vite plugins will not be loaded.
|
||||
DISABLE_VUE_PLUGINS=false
|
||||
|
||||
72
.github/workflows/dev-release.yaml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Create Dev PyPI Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
devVersion:
|
||||
description: 'Dev version'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.current_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
34
CLAUDE.md
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
- Be sure to run unit tests, component tests, browser tests then typecheck, lint, format (with prettier) when you're done making a series of code changes. You can find the scripts for all these things in the package.json.
|
||||
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
|
||||
- Never add lines to PR descriptions that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
- Prefer running single tests, and not the whole test suite, for performance
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- Use setup() function for component logic
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity.
|
||||
- Use Tailwind CSS for styling
|
||||
- Leverage VueUse functions for performance-enhancing styles
|
||||
- Use lodash for utility functions
|
||||
- Use TypeScript for type safety
|
||||
- Implement proper props and emits definitions
|
||||
- Utilize Vue 3's Teleport component when needed
|
||||
- Use Suspense for async components
|
||||
- Implement proper error handling
|
||||
- Follow Vue 3 style guide and naming conventions
|
||||
- Use Vite for fast development and building
|
||||
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
|
||||
148
README.md
@@ -526,14 +526,46 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
|
||||
## Development
|
||||
|
||||
### Tech Stack
|
||||
### Prerequisites & Technology 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
|
||||
- **Required Software**:
|
||||
- Node.js (v16 or later) 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
|
||||
|
||||
@@ -575,7 +607,7 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Unit Test
|
||||
|
||||
@@ -606,3 +638,103 @@ This will replace the litegraph package in this repo with the local litegraph re
|
||||
### i18n
|
||||
|
||||
See [locales/README.md](src/locales/README.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
|
||||
|
||||
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
|
||||
|
||||
### Debugging Custom Node (Extension) Issues
|
||||
|
||||
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
|
||||
|
||||
#### Step 1: Verify if custom nodes are causing the problem
|
||||
|
||||
Run ComfyUI with the `--disable-all-custom-nodes` flag:
|
||||
|
||||
```bash
|
||||
python main.py --disable-all-custom-nodes
|
||||
```
|
||||
|
||||
If the issue disappears, a custom node is the culprit. Proceed to the next step.
|
||||
|
||||
#### Step 2: Identify the problematic custom node using binary search
|
||||
|
||||
Rather than disabling nodes one by one, use this more efficient approach:
|
||||
|
||||
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
|
||||
```bash
|
||||
# Create a temporary directory
|
||||
# Linux/Mac
|
||||
mkdir ~/custom_nodes_disabled
|
||||
|
||||
# Windows
|
||||
mkdir %USERPROFILE%\custom_nodes_disabled
|
||||
|
||||
# Move half of your custom nodes (assuming you have node1 through node8)
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
2. Run ComfyUI again
|
||||
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
|
||||
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
|
||||
|
||||
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
|
||||
```bash
|
||||
# Move nodes 3-4 back to custom_nodes
|
||||
# Linux/Mac
|
||||
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
|
||||
|
||||
# Windows
|
||||
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
|
||||
```
|
||||
|
||||
4. Run ComfyUI again
|
||||
- If the issue reappears: The problem is in nodes 3-4
|
||||
- If issue still gone: The problem is in nodes 1-2
|
||||
|
||||
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
|
||||
```bash
|
||||
# Move node 3 back to disabled
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node3 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
6. Run ComfyUI again
|
||||
- If the issue disappears: node3 is the problem
|
||||
- If issue persists: node4 is the problem
|
||||
|
||||
7. Repeat until you identify the specific problematic node
|
||||
|
||||
#### Step 3: Update or replace the problematic node
|
||||
|
||||
Once identified:
|
||||
1. Check for updates to the problematic custom node
|
||||
2. Consider alternatives with similar functionality
|
||||
3. Report the issue to the custom node developer with specific details
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
|
||||
```bash
|
||||
pip install -r custom_nodes/problematic_node/requirements.txt
|
||||
```
|
||||
|
||||
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
|
||||
|
||||
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
|
||||
|
||||
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Playwright Testing for ComfyUI_frontend
|
||||
|
||||
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
|
||||
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
|
||||
|
||||
## WARNING
|
||||
|
||||
@@ -31,7 +31,7 @@ If you are running Playwright tests in parallel or running the same test multipl
|
||||
|
||||
## Running Tests
|
||||
|
||||
There are two ways to run the tests:
|
||||
There are multiple ways to run the tests:
|
||||
|
||||
1. **Headless mode with report generation:**
|
||||
```bash
|
||||
@@ -47,14 +47,239 @@ There are two ways to run the tests:
|
||||
|
||||

|
||||
|
||||
3. **Running specific tests:**
|
||||
```bash
|
||||
npx playwright test widget.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Browser tests in this project follow a specific organization pattern:
|
||||
|
||||
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
|
||||
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
|
||||
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
|
||||
- Components fixtures in `fixtures/components/` - Page object models for UI components
|
||||
|
||||
- **Tests**: Located in `tests/` - The actual test specifications
|
||||
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
|
||||
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
|
||||
|
||||
- **Utilities**: Located in `utils/` - Common utility functions
|
||||
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
|
||||
|
||||
## Writing Effective Tests
|
||||
|
||||
When writing new tests, follow these patterns:
|
||||
|
||||
### Test Structure
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Common setup
|
||||
});
|
||||
|
||||
test('should do something specific', async ({ comfyPage }) => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Leverage Existing Fixtures and Helpers
|
||||
|
||||
Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
|
||||
- **ComfyMouse**: Helper for precise mouse operations on the canvas
|
||||
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
|
||||
- `actionbar.ts`: Interact with the action bar
|
||||
- `manageGroupNode.ts`: Group node management operations
|
||||
- `templates.ts`: Template workflows operations
|
||||
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
|
||||
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
Canvas-based elements often need explicit focus before interaction:
|
||||
```typescript
|
||||
// Click the canvas first to focus it before pressing keys
|
||||
await comfyPage.canvas.click();
|
||||
await comfyPage.page.keyboard.press('a');
|
||||
```
|
||||
|
||||
2. **Mark canvas as dirty if needed**:
|
||||
Some interactions need explicit canvas updates:
|
||||
```typescript
|
||||
// After programmatically changing node state, mark canvas dirty
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].graph.setDirtyCanvas(true, true);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Use node references over coordinates**:
|
||||
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
|
||||
```typescript
|
||||
// Prefer this:
|
||||
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
|
||||
await node.click('title');
|
||||
|
||||
// Over this:
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
|
||||
```
|
||||
|
||||
4. **Wait for canvas to render after UI interactions**:
|
||||
```typescript
|
||||
await comfyPage.nextFrame();
|
||||
```
|
||||
|
||||
5. **Clean up persistent server state**:
|
||||
While most state is reset between tests, anything stored on the server persists:
|
||||
```typescript
|
||||
// Reset settings that affect other tests (these are stored on server)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
|
||||
|
||||
// Clean up uploaded files if needed
|
||||
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
|
||||
```
|
||||
|
||||
6. **Prefer functional assertions over screenshots**:
|
||||
Use screenshots only when visual verification is necessary:
|
||||
```typescript
|
||||
// Prefer this:
|
||||
expect(await node.isPinned()).toBe(true);
|
||||
expect(await node.getProperty('title')).toBe('Expected Title');
|
||||
|
||||
// Over this - only use when needed:
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
|
||||
```
|
||||
|
||||
7. **Use minimal test workflows**:
|
||||
When creating test workflows, keep them as minimal as possible:
|
||||
```typescript
|
||||
// Include only the components needed for the test
|
||||
await comfyPage.loadWorkflow('single_ksampler');
|
||||
```
|
||||
|
||||
## Common Patterns and Utilities
|
||||
|
||||
### Page Object Pattern
|
||||
|
||||
Tests use the Page Object pattern to create abstractions over the UI:
|
||||
|
||||
```typescript
|
||||
// Using the ComfyPage fixture
|
||||
test('Can toggle boolean widget', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/boolean_widget')
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await widget.click()
|
||||
});
|
||||
```
|
||||
|
||||
### Node References
|
||||
|
||||
The `NodeReference` class provides helpers for interacting with LiteGraph nodes:
|
||||
|
||||
```typescript
|
||||
// Getting node by type and interacting with it
|
||||
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
|
||||
const loadImageNode = nodes[0]
|
||||
const widget = await loadImageNode.getWidget(0)
|
||||
await widget.click()
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Tests use screenshot comparisons to verify UI state:
|
||||
|
||||
```typescript
|
||||
// Take a screenshot and compare to reference
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget_toggled.png')
|
||||
```
|
||||
|
||||
### Waiting for Animations
|
||||
|
||||
Always call `nextFrame()` after actions that trigger animations:
|
||||
|
||||
```typescript
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame() // Wait for canvas to redraw
|
||||
```
|
||||
|
||||
### Mouse Interactions
|
||||
|
||||
Canvas operations use special helpers to ensure proper timing:
|
||||
|
||||
```typescript
|
||||
// Using ComfyMouse for drag and drop
|
||||
await comfyMouse.dragAndDrop(
|
||||
{ x: 100, y: 100 }, // From
|
||||
{ x: 200, y: 200 } // To
|
||||
)
|
||||
|
||||
// Standard ComfyPage helpers
|
||||
await comfyPage.drag({ x: 100, y: 100 }, { x: 200, y: 200 })
|
||||
await comfyPage.pan({ x: 200, y: 200 })
|
||||
await comfyPage.zoom(-100) // Zoom in
|
||||
```
|
||||
|
||||
### Workflow Management
|
||||
|
||||
Tests use workflows stored in `assets/` for consistent starting points:
|
||||
|
||||
```typescript
|
||||
// Load a test workflow
|
||||
await comfyPage.loadWorkflow('single_ksampler')
|
||||
|
||||
// Wait for workflow to load and stabilize
|
||||
await comfyPage.nextFrame()
|
||||
```
|
||||
|
||||
### Custom Assertions
|
||||
|
||||
The project includes custom Playwright assertions through `comfyExpect`:
|
||||
|
||||
```typescript
|
||||
// Check if a node is in a specific state
|
||||
await expect(node).toBePinned()
|
||||
await expect(node).toBeBypassed()
|
||||
await expect(node).toBeCollapsed()
|
||||
```
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Flaky Tests
|
||||
|
||||
- **Timing Issues**: Always wait for animations to complete with `nextFrame()`
|
||||
- **Coordinate Sensitivity**: Canvas coordinates are viewport-relative; use node references when possible
|
||||
- **Test Isolation**: Tests run in parallel; avoid dependencies between tests
|
||||
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
|
||||
- **Async / await**: Race conditions are a very common cause of test flakiness
|
||||
|
||||
## Screenshot Expectations
|
||||
|
||||
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
|
||||
|
||||
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment.
|
||||
- To set new test expectations:
|
||||
1. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch.
|
||||
2. Add the `New Browser Test Expectation` tag to your pull request.
|
||||
3. This will trigger a GitHub action to update the screenshot expectations automatically.
|
||||
- **DO NOT commit local screenshot expectations** to the repository
|
||||
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
|
||||
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
|
||||
|
||||
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
|
||||
To set new test expectations for PR:
|
||||
|
||||
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
|
||||
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
|
||||
3. Add the `New Browser Test Expectation` tag to your pull request
|
||||
4. The GitHub CI will automatically generate and commit the reference screenshots
|
||||
|
||||
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
|
||||
|
||||
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
|
||||
@@ -133,6 +133,9 @@ export class ComfyPage {
|
||||
// Inputs
|
||||
public readonly workflowUploadInput: Locator
|
||||
|
||||
// Toasts
|
||||
public readonly visibleToasts: Locator
|
||||
|
||||
// Components
|
||||
public readonly searchBox: ComfyNodeSearchBox
|
||||
public readonly menu: ComfyMenu
|
||||
@@ -159,6 +162,8 @@ export class ComfyPage {
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
@@ -270,7 +275,6 @@ export class ComfyPage {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
localStorage.setItem('api-nodes-news-seen', 'true')
|
||||
}, this.id)
|
||||
}
|
||||
await this.goto()
|
||||
@@ -397,6 +401,30 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async deleteWorkflow(
|
||||
workflowName: string,
|
||||
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
|
||||
) {
|
||||
// Open workflows tab
|
||||
const { workflowsTab } = this.menu
|
||||
await workflowsTab.open()
|
||||
|
||||
// Action to take if workflow missing
|
||||
if (whenMissing === 'ignoreMissing') {
|
||||
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
|
||||
if (!workflows.includes(workflowName)) return
|
||||
}
|
||||
|
||||
// Delete workflow
|
||||
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
|
||||
await this.clickContextMenuItem('Delete')
|
||||
await this.confirmDialog.delete.click()
|
||||
|
||||
// Clear toast & close tab
|
||||
await this.closeToasts(1)
|
||||
await workflowsTab.close()
|
||||
}
|
||||
|
||||
async resetView() {
|
||||
if (await this.resetViewButton.isVisible()) {
|
||||
await this.resetViewButton.click()
|
||||
@@ -413,7 +441,20 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async getVisibleToastCount() {
|
||||
return await this.page.locator('.p-toast-message:visible').count()
|
||||
return await this.visibleToasts.count()
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0) {
|
||||
if (requireCount) await expect(this.visibleToasts).toHaveCount(requireCount)
|
||||
|
||||
// Clear all toasts
|
||||
const toastCloseButtons = await this.page
|
||||
.locator('.p-toast-close-button')
|
||||
.all()
|
||||
for (const button of toastCloseButtons) {
|
||||
await button.click()
|
||||
}
|
||||
await expect(this.visibleToasts).toHaveCount(0)
|
||||
}
|
||||
|
||||
async clickTextEncodeNode1() {
|
||||
|
||||
139
browser_tests/tests/chatHistory.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
response_id: string
|
||||
}
|
||||
|
||||
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
|
||||
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
|
||||
// Simulate API sending display_component message
|
||||
await page.evaluate(
|
||||
({ nodeId, history }) => {
|
||||
const event = new CustomEvent('display_component', {
|
||||
detail: {
|
||||
node_id: nodeId,
|
||||
component: 'ChatHistoryWidget',
|
||||
props: {
|
||||
history: JSON.stringify(history)
|
||||
}
|
||||
}
|
||||
})
|
||||
window['app'].api.dispatchEvent(event)
|
||||
return true
|
||||
},
|
||||
{ nodeId, history }
|
||||
)
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
test.describe('Chat History Widget', () => {
|
||||
let nodeId: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
nodeId = await renderChatHistory(comfyPage.page, [
|
||||
{ prompt: 'Hello', response: 'World', response_id: '123' }
|
||||
])
|
||||
// Wait for chat history to be rendered
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
})
|
||||
|
||||
test('displays chat history when receiving display_component message', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Verify the chat history is displayed correctly
|
||||
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('World')).toBeVisible()
|
||||
})
|
||||
|
||||
test('handles message editing interaction', async ({ comfyPage }) => {
|
||||
// Get first node's ID
|
||||
nodeId = await comfyPage.page.evaluate(() => {
|
||||
const node = window['app'].graph.nodes[0]
|
||||
|
||||
// Make sure the node has a prompt widget (for editing functionality)
|
||||
if (!node.widgets) {
|
||||
node.widgets = []
|
||||
}
|
||||
|
||||
// Add a prompt widget if it doesn't exist
|
||||
if (!node.widgets.find((w) => w.name === 'prompt')) {
|
||||
node.widgets.push({
|
||||
name: 'prompt',
|
||||
type: 'text',
|
||||
value: 'Original prompt'
|
||||
})
|
||||
}
|
||||
|
||||
return node.id
|
||||
})
|
||||
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Message 1',
|
||||
response: 'Response 1',
|
||||
response_id: '123'
|
||||
},
|
||||
{
|
||||
prompt: 'Message 2',
|
||||
response: 'Response 2',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
const originalTextAreaInput = await comfyPage.page
|
||||
.getByPlaceholder('text')
|
||||
.nth(1)
|
||||
.inputValue()
|
||||
|
||||
// Click edit button on first message
|
||||
await comfyPage.page.getByLabel('Edit').first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify cancel button appears
|
||||
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
|
||||
|
||||
// Click cancel edit
|
||||
await comfyPage.page.getByLabel('Cancel').click()
|
||||
|
||||
// Verify prompt input is restored
|
||||
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
|
||||
originalTextAreaInput
|
||||
)
|
||||
})
|
||||
|
||||
test('handles real-time updates to chat history', async ({ comfyPage }) => {
|
||||
// Send initial history
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Initial message',
|
||||
response: 'Initial response',
|
||||
response_id: '123'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Update history with additional messages
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Follow-up',
|
||||
response: 'New response',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Move mouse over the canvas to force update
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify new messages appear
|
||||
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('New response')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -689,3 +689,42 @@ test.describe('Load duplicate workflow', () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Viewport settings', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||
|
||||
await comfyPage.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Keeps viewport settings when changing tabs', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// Save workflow as a new file, then zoom out before screen shot
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
|
||||
await comfyMouse.move(comfyPage.emptySpace)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await comfyMouse.wheel(0, 60)
|
||||
}
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
|
||||
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -142,4 +142,136 @@ test.describe('Templates', () => {
|
||||
// Expect the title to be used as fallback for the template categories
|
||||
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Wait for at least one template card to appear
|
||||
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Take snapshot of the template grid
|
||||
const templateGrid = comfyPage.templates.content.locator('.grid').first()
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
|
||||
|
||||
// Check cards at mobile viewport size
|
||||
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
|
||||
|
||||
// Check cards at tablet size
|
||||
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
|
||||
})
|
||||
|
||||
test('hover effects work on template cards', async ({ comfyPage }) => {
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Get a template card
|
||||
const firstCard = comfyPage.page.locator('.template-card').first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Take snapshot before hover
|
||||
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
|
||||
|
||||
// Hover over the card
|
||||
await firstCard.hover()
|
||||
|
||||
// Take snapshot after hover to verify hover effect
|
||||
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
|
||||
})
|
||||
|
||||
test('template cards descriptions adjust height dynamically', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Setup test by intercepting templates response to inject cards with varying description lengths
|
||||
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'short-description',
|
||||
title: 'Short Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'This is a short description.'
|
||||
},
|
||||
{
|
||||
name: 'medium-description',
|
||||
title: 'Medium Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a medium length description that should take up two lines on most displays.'
|
||||
},
|
||||
{
|
||||
name: 'long-description',
|
||||
title: 'Long Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Mock the thumbnail images to avoid 404s
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
const headers = {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers
|
||||
})
|
||||
})
|
||||
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Verify cards are visible with varying content lengths
|
||||
await expect(
|
||||
comfyPage.page.getByText('This is a short description.')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('This is a medium length description')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('This is a much longer description')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Take snapshot of a grid with specific cards
|
||||
const templateGrid = comfyPage.templates.content
|
||||
.locator('.grid:has-text("Short Description")')
|
||||
.first()
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-grid-varying-content.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -6,7 +6,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
|
||||
|
||||
<!-- Fullscreen mode on iOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app"></div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
|
||||
9
manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "ComfyUI",
|
||||
"short_name": "ComfyUI",
|
||||
"description": "ComfyUI: AI image generation platform",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
}
|
||||
1719
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.19.8",
|
||||
"version": "1.20.3",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -63,6 +63,8 @@
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^2.0.0",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"zip-dir": "^2.0.0",
|
||||
@@ -72,7 +74,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.8",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.5",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
BIN
public/assets/images/favicon_progress_16x16/frame_0.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
public/assets/images/favicon_progress_16x16/frame_1.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_2.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_3.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_4.png
Normal file
|
After Width: | Height: | Size: 698 B |
BIN
public/assets/images/favicon_progress_16x16/frame_5.png
Normal file
|
After Width: | Height: | Size: 700 B |
BIN
public/assets/images/favicon_progress_16x16/frame_6.png
Normal file
|
After Width: | Height: | Size: 702 B |
BIN
public/assets/images/favicon_progress_16x16/frame_7.png
Normal file
|
After Width: | Height: | Size: 705 B |
BIN
public/assets/images/favicon_progress_16x16/frame_8.png
Normal file
|
After Width: | Height: | Size: 708 B |
BIN
public/assets/images/favicon_progress_16x16/frame_9.png
Normal file
|
After Width: | Height: | Size: 705 B |
@@ -1 +0,0 @@
|
||||
/* Put custom styles here */
|
||||
|
Before Width: | Height: | Size: 36 KiB |
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- This component does not render anything visible. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle
|
||||
? ''
|
||||
: `[${Math.round(executionStore.executionProgress * 100)}%]`
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const newMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isUnsavedText = computed(() =>
|
||||
workflowStore.activeWorkflow?.isModified ||
|
||||
!workflowStore.activeWorkflow?.isPersisted
|
||||
? ' *'
|
||||
: ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
return workflowName
|
||||
? isUnsavedText.value + workflowName + TITLE_SUFFIX
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
executionText.value +
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
useTitle(title)
|
||||
</script>
|
||||
@@ -63,15 +63,6 @@ watchDebounced(
|
||||
|
||||
// Set initial position to bottom center
|
||||
const setInitialPosition = () => {
|
||||
if (x.value !== 0 || y.value !== 0) {
|
||||
return
|
||||
}
|
||||
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
|
||||
x.value = storedPosition.value.x
|
||||
y.value = storedPosition.value.y
|
||||
captureLastDragState()
|
||||
return
|
||||
}
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
@@ -82,9 +73,25 @@ const setInitialPosition = () => {
|
||||
return
|
||||
}
|
||||
|
||||
x.value = (screenWidth - menuWidth) / 2
|
||||
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
|
||||
captureLastDragState()
|
||||
// Check if stored position exists and is within bounds
|
||||
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
|
||||
// Ensure stored position is within screen bounds
|
||||
x.value = clamp(storedPosition.value.x, 0, screenWidth - menuWidth)
|
||||
y.value = clamp(storedPosition.value.y, 0, screenHeight - menuHeight)
|
||||
captureLastDragState()
|
||||
return
|
||||
}
|
||||
|
||||
// If no stored position or current position, set to bottom center
|
||||
if (x.value === 0 && y.value === 0) {
|
||||
x.value = clamp((screenWidth - menuWidth) / 2, 0, screenWidth - menuWidth)
|
||||
y.value = clamp(
|
||||
screenHeight - menuHeight - 10,
|
||||
0,
|
||||
screenHeight - menuHeight
|
||||
)
|
||||
captureLastDragState()
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(setInitialPosition)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -14,28 +11,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -43,7 +42,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: async () => {
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -55,14 +54,17 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -70,11 +70,12 @@ const state = computed<GridState>(() => {
|
||||
const fromCol = fromRow * cols.value
|
||||
const toCol = toRow * cols.value
|
||||
const remainingCol = items.length - toCol
|
||||
const hasMoreToRender = remainingCol >= 0
|
||||
|
||||
return {
|
||||
start: clamp(fromCol, 0, items?.length),
|
||||
end: clamp(toCol, fromCol, items?.length),
|
||||
isNearEnd: remainingCol <= cols.value * bufferRows
|
||||
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
|
||||
}
|
||||
})
|
||||
const renderedItems = computed(() =>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-12 p-2 w-96">
|
||||
<img src="@/assets/images/api-nodes-news.webp" alt="API Nodes News" />
|
||||
<div class="flex flex-col gap-2 justify-center items-center">
|
||||
<div class="text-xl">
|
||||
{{ $t('apiNodesNews.introducing') }}
|
||||
<span class="text-amber-500">API NODES</span>
|
||||
</div>
|
||||
<div class="text-muted">{{ $t('apiNodesNews.subtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="grid grid-cols-[auto_1fr] gap-2 items-center"
|
||||
>
|
||||
<Tag class="w-8 h-8" :value="index + 1" rounded />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>{{ step.title }}</div>
|
||||
<div v-if="step.subtitle" class="text-muted">
|
||||
{{ step.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<Button label="Learn More" text @click="handleLearnMore" />
|
||||
<Button label="Close" @click="onClose" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const steps: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}[] = [
|
||||
{
|
||||
title: t('apiNodesNews.steps.step1.title'),
|
||||
subtitle: t('apiNodesNews.steps.step1.subtitle')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step2.title'),
|
||||
subtitle: t('apiNodesNews.steps.step2.subtitle')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step3.title')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step4.title')
|
||||
}
|
||||
]
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const handleLearnMore = () => {
|
||||
window.open('https://blog.comfy.org/p/comfyui-native-api-nodes', '_blank')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
localStorage.setItem('api-nodes-news-seen', 'true')
|
||||
})
|
||||
</script>
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div class="flex justify-end py-3">
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,6 +42,7 @@ import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
@@ -49,6 +50,19 @@ const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
)
|
||||
})
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
|
||||
@@ -67,9 +67,9 @@ import Tabs from 'primevue/tabs'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
|
||||
import { useSettingUI } from '@/composables/setting/useSettingUI'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { SettingTreeNode } from '@/stores/settingStore'
|
||||
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
@@ -107,7 +107,7 @@ const {
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
@@ -140,7 +140,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
activeCategory.value = oldValue
|
||||
}
|
||||
if (activeCategory.value?.key === 'credits') {
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -101,6 +101,15 @@
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
</small>
|
||||
<Message
|
||||
v-if="authActions.accessError.value"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
variant="outlined"
|
||||
closable
|
||||
>
|
||||
{{ t('toastMessages.useApiKeyTip') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
@@ -134,12 +143,12 @@
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
@@ -151,7 +160,7 @@ const { onSuccess } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
@@ -162,25 +171,25 @@ const toggleState = () => {
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
if (await authService.signInWithGoogle()) {
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
if (await authService.signInWithGithub()) {
|
||||
if (await authActions.signInWithGithub()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async (values: SignInData) => {
|
||||
if (await authService.signInWithEmail(values.email, values.password)) {
|
||||
if (await authActions.signInWithEmail(values.email, values.password)) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authService.signUpWithEmail(values.email, values.password)) {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
@@ -189,4 +198,8 @@ const userIsInChina = ref(false)
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
authActions.accessError.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
@@ -65,9 +65,9 @@ const {
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const handleSeeDetails = async () => {
|
||||
await authService.accessBillingPortal()
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,10 +23,10 @@ import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { updatePasswordSchema } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const loading = ref(false)
|
||||
|
||||
const { onSuccess } = defineProps<{
|
||||
@@ -37,7 +37,7 @@ const onSubmit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authService.updatePassword(event.values.password)
|
||||
await authActions.updatePassword(event.values.password)
|
||||
onSuccess()
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -41,9 +41,9 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const {
|
||||
amount,
|
||||
@@ -61,7 +61,7 @@ const loading = ref(false)
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
loading.value = true
|
||||
await authService.purchaseCredits(editable ? customAmount.value : amount)
|
||||
await authActions.purchaseCredits(editable ? customAmount.value : amount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const handleBuyNow = async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -124,12 +124,16 @@
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value"
|
||||
v-if="$field?.error && $field.touched"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.maxLength') }}
|
||||
{{
|
||||
$field.value
|
||||
? t('issueReport.validation.maxLength')
|
||||
: t('issueReport.validation.descriptionRequired')
|
||||
}}
|
||||
</Message>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
/>
|
||||
<div v-else class="h-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:grid-style="GRID_STYLE"
|
||||
@@ -92,7 +93,7 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -199,6 +200,10 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -419,6 +424,17 @@ whenever(selectedNodePack, async () => {
|
||||
}
|
||||
})
|
||||
|
||||
let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
getPackById.cancel()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
text
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="() => authService.fetchBalance()"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,8 +112,8 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
@@ -127,7 +127,7 @@ interface CreditHistoryItemData {
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -142,7 +142,7 @@ const handlePurchaseCreditsClick = () => {
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authService.accessBillingPortal()
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
<h3 class="font-medium">
|
||||
{{ $t('userSettings.email') }}
|
||||
</h3>
|
||||
<a :href="'mailto:' + userEmail" class="hover:underline">
|
||||
<span class="text-muted">
|
||||
{{ userEmail }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
|
||||
@@ -80,12 +80,12 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthService = useFirebaseAuthService()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -102,6 +102,6 @@ const onSubmit = (event: FormSubmitEvent) => {
|
||||
|
||||
const handleForgotPassword = async (email: string) => {
|
||||
if (!email) return
|
||||
await firebaseAuthService.sendPasswordReset(email)
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,24 +12,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, watch } from 'vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { type DomWidgetState, useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(
|
||||
() => Array.from(domWidgetStore.widgetStates.values()) as DomWidgetState[]
|
||||
)
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
@@ -54,18 +53,13 @@ const updateWidgets = () => {
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
whenever(
|
||||
() => canvasStore.canvas,
|
||||
(lgCanvas) => {
|
||||
if (!lgCanvas) return
|
||||
|
||||
lgCanvas.onDrawForeground = useChainCallback(
|
||||
lgCanvas.onDrawForeground,
|
||||
() => {
|
||||
updateWidgets()
|
||||
}
|
||||
)
|
||||
},
|
||||
(canvas) =>
|
||||
(canvas.onDrawForeground = useChainCallback(
|
||||
canvas.onDrawForeground,
|
||||
updateWidgets
|
||||
)),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -27,7 +29,6 @@
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<NodeBadge />
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover />
|
||||
|
||||
@@ -40,12 +41,11 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -53,7 +53,6 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeBadge from '@/components/graph/NodeBadge.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
@@ -62,6 +61,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
@@ -71,7 +71,7 @@ import { usePaste } from '@/composables/usePaste'
|
||||
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n } from '@/i18n'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -85,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -225,39 +226,13 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Save the drag & scale info in the serialized workflow if the setting is enabled
|
||||
watch(
|
||||
[
|
||||
() => canvasStore.canvas,
|
||||
() => settingStore.get('Comfy.EnableWorkflowViewRestore')
|
||||
],
|
||||
([canvas, enableWorkflowViewRestore]) => {
|
||||
const extra = canvas?.graph?.extra
|
||||
if (!extra) return
|
||||
|
||||
if (enableWorkflowViewRestore) {
|
||||
extra.ds = {
|
||||
get scale() {
|
||||
return canvas.ds.scale
|
||||
},
|
||||
get offset() {
|
||||
const [x, y] = canvas.ds.offset
|
||||
return [x, y]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete extra.ds
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
canvasRef,
|
||||
'litegraph:no-items-selected',
|
||||
() => {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'No items selected',
|
||||
summary: t('toastMessages.nothingSelected'),
|
||||
life: 2000
|
||||
})
|
||||
},
|
||||
@@ -280,6 +255,7 @@ const workflowPersistence = useWorkflowPersistence()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
onMounted(async () => {
|
||||
useGlobalLitegraph()
|
||||
@@ -293,7 +269,7 @@ onMounted(async () => {
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init(comfyApp)
|
||||
ChangeTracker.init()
|
||||
await loadCustomNodesI18n()
|
||||
try {
|
||||
await settingStore.loadSettingValues()
|
||||
@@ -318,10 +294,8 @@ onMounted(async () => {
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['app'] = comfyApp
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['graph'] = comfyApp.graph
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
@@ -348,6 +322,16 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- This component does not render anything visible. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgePosition,
|
||||
LGraphBadge,
|
||||
type LGraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const nodeSourceBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
|
||||
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
|
||||
app.graph?.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function badgeTextVisible(
|
||||
nodeDef: ComfyNodeDefImpl | null,
|
||||
badgeMode: NodeBadgeMode
|
||||
): boolean {
|
||||
return !(
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
app.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
return new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
|
||||
? `#${node.id}`
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
|
||||
? nodeDef?.nodeLifeCycleBadgeText ?? ''
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
|
||||
? nodeDef?.nodeSource?.badgeText ?? ''
|
||||
: ''
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node) {
|
||||
const creditsBadge = computed(() => {
|
||||
return new LGraphBadge({
|
||||
text: '',
|
||||
iconOptions: {
|
||||
unicode: '\ue96b',
|
||||
fontFamily: 'PrimeIcons',
|
||||
color: '#FABC25',
|
||||
bgColor: '#353535',
|
||||
fontSize: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -14,12 +14,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createBounds } from '@comfyorg/litegraph'
|
||||
import type { LGraphCanvas } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -28,8 +26,8 @@ const { style, updatePosition } = useAbsolutePosition()
|
||||
const visible = ref(false)
|
||||
const showBorder = ref(false)
|
||||
|
||||
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
|
||||
const selectedItems = canvas.selectedItems
|
||||
const positionSelectionOverlay = () => {
|
||||
const { selectedItems } = canvasStore.getCanvas()
|
||||
showBorder.value = selectedItems.size > 1
|
||||
|
||||
if (!selectedItems.size) {
|
||||
@@ -48,26 +46,18 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
|
||||
}
|
||||
|
||||
// Register listener on canvas creation.
|
||||
watch(
|
||||
() => canvasStore.canvas as LGraphCanvas | null,
|
||||
(canvas: LGraphCanvas | null) => {
|
||||
if (!canvas) return
|
||||
|
||||
canvas.onSelectionChange = useChainCallback(
|
||||
canvas.onSelectionChange,
|
||||
// Wait for next frame as sometimes the selected items haven't been
|
||||
// rendered yet, so the boundingRect is not available on them.
|
||||
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
|
||||
)
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
positionSelectionOverlay()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().ds.state,
|
||||
() => positionSelectionOverlay(canvasStore.getCanvas()),
|
||||
{ deep: true }
|
||||
)
|
||||
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
|
||||
|
||||
watch(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
@@ -77,10 +67,10 @@ watch(
|
||||
// the correct position.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
|
||||
if (draggingItems === false) {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
|
||||
}, 100)
|
||||
positionSelectionOverlay()
|
||||
})
|
||||
} else {
|
||||
// Selection change update to visible state is delayed by a frame. Here
|
||||
// we also delay a frame so that the order of events is correct when
|
||||
|
||||
@@ -6,96 +6,44 @@
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
>
|
||||
<ExecuteButton v-show="nodeSelected" />
|
||||
<ColorPickerButton v-show="nodeSelected || groupSelected" />
|
||||
<Button
|
||||
v-show="nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-show="nodeSelected || groupSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
<Button
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
v-tooltip.top="{
|
||||
value:
|
||||
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
:command="command"
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
import { st, t } from '@/i18n'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
const nodeSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphNode)
|
||||
)
|
||||
const groupSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphGroup)
|
||||
)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
canvasStore.selectedItems
|
||||
.map(
|
||||
@@ -108,7 +56,7 @@ const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
|
||||
)
|
||||
return Array.from(commandIds)
|
||||
.map((commandId) => commandStore.getCommand(commandId))
|
||||
.filter((command) => command !== undefined)
|
||||
.filter((command): command is ComfyCommandImpl => command !== undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
31
src/components/graph/selectionToolbox/BypassButton.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
</script>
|
||||
30
src/components/graph/selectionToolbox/DeleteButton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: isDisabled
|
||||
? t('selectionToolbox.executeButton.disabledTooltip')
|
||||
@@ -36,7 +37,7 @@ const buttonHovered = ref(false)
|
||||
const selectedOutputNodes = computed(
|
||||
() =>
|
||||
canvasStore.selectedItems.filter(
|
||||
(item) => isLGraphNode(item) && item.constructor.nodeData.output_node
|
||||
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
|
||||
) as LGraphNode[]
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
|
||||
function outputNodeStokeStyle(this: LGraphNode) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.constructor.nodeData.output_node &&
|
||||
this.constructor.nodeData?.output_node &&
|
||||
buttonHovered.value
|
||||
) {
|
||||
return { color: 'orange', lineWidth: 2, padding: 10 }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value:
|
||||
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
defineProps<{
|
||||
command: ComfyCommand
|
||||
}>()
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
</script>
|
||||
36
src/components/graph/selectionToolbox/MaskEditorButton.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isSingleImageNode"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_MaskEditor_OpenMaskEditor.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pencil"
|
||||
@click="openMaskEditor"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
</script>
|
||||
25
src/components/graph/selectionToolbox/PinButton.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
17
src/components/graph/selectionToolbox/RefreshButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
</script>
|
||||
95
src/components/graph/widgets/ChatHistoryWidget.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { edit: 'Edit' },
|
||||
chatHistory: {
|
||||
cancelEdit: 'Cancel edit',
|
||||
cancelEditTooltip: 'Cancel edit'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/graph/widgets/chatHistory/CopyButton.vue', () => ({
|
||||
default: {
|
||||
name: 'CopyButton',
|
||||
template: '<div class="mock-copy-button"></div>',
|
||||
props: ['text']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/graph/widgets/chatHistory/ResponseBlurb.vue', () => ({
|
||||
default: {
|
||||
name: 'ResponseBlurb',
|
||||
template: '<div class="mock-response-blurb"><slot /></div>',
|
||||
props: ['text']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ChatHistoryWidget.vue', () => {
|
||||
const mockHistory = JSON.stringify([
|
||||
{ prompt: 'Test prompt', response: 'Test response', response_id: '123' }
|
||||
])
|
||||
|
||||
const mountWidget = (props: { history: string; widget?: any }) => {
|
||||
return mount(ChatHistoryWidget, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['icon', 'aria-label']
|
||||
},
|
||||
ScrollPanel: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders chat history correctly', () => {
|
||||
const wrapper = mountWidget({ history: mockHistory })
|
||||
expect(wrapper.text()).toContain('Test prompt')
|
||||
expect(wrapper.text()).toContain('Test response')
|
||||
})
|
||||
|
||||
it('handles empty history', () => {
|
||||
const wrapper = mountWidget({ history: '[]' })
|
||||
expect(wrapper.find('.mb-4').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('edits previous prompts', () => {
|
||||
const mockWidget = {
|
||||
node: { widgets: [{ name: 'prompt', value: '' }] }
|
||||
}
|
||||
|
||||
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
|
||||
const vm = wrapper.vm as any
|
||||
vm.handleEdit(0)
|
||||
|
||||
expect(mockWidget.node.widgets[0].value).toContain('Test prompt')
|
||||
expect(mockWidget.node.widgets[0].value).toContain('starting_point_id')
|
||||
})
|
||||
|
||||
it('cancels editing correctly', () => {
|
||||
const mockWidget = {
|
||||
node: { widgets: [{ name: 'prompt', value: 'Original value' }] }
|
||||
}
|
||||
|
||||
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
|
||||
const vm = wrapper.vm as any
|
||||
|
||||
vm.handleEdit(0)
|
||||
vm.handleCancelEdit()
|
||||
|
||||
expect(mockWidget.node.widgets[0].value).toBe('Original value')
|
||||
})
|
||||
})
|
||||
134
src/components/graph/widgets/ChatHistoryWidget.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<ScrollPanel
|
||||
ref="scrollPanelRef"
|
||||
class="w-full min-h-[400px] rounded-lg px-2 py-2 text-xs"
|
||||
:pt="{ content: { id: 'chat-scroll-content' } }"
|
||||
>
|
||||
<div v-for="(item, i) in parsedHistory" :key="i" class="mb-4">
|
||||
<!-- Prompt (user, right) -->
|
||||
<span
|
||||
:class="{
|
||||
'opacity-40 pointer-events-none': editIndex !== null && i > editIndex
|
||||
}"
|
||||
>
|
||||
<div class="flex justify-end mb-1">
|
||||
<div
|
||||
class="bg-gray-300 dark-theme:bg-gray-800 rounded-xl px-4 py-1 max-w-[80%] text-right"
|
||||
>
|
||||
<div class="break-words text-[12px]">{{ item.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mb-2 mr-1">
|
||||
<CopyButton :text="item.prompt" />
|
||||
<Button
|
||||
v-tooltip="
|
||||
editIndex === i ? $t('chatHistory.cancelEditTooltip') : null
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="!p-1 !h-4 !w-4 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
|
||||
pt:icon:class="!text-xs"
|
||||
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
|
||||
:aria-label="
|
||||
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
|
||||
"
|
||||
@click="editIndex === i ? handleCancelEdit() : handleEdit(i)"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<!-- Response (LLM, left) -->
|
||||
<ResponseBlurb
|
||||
:text="item.response"
|
||||
:class="{
|
||||
'opacity-25 pointer-events-none': editIndex !== null && i >= editIndex
|
||||
}"
|
||||
>
|
||||
<div v-html="nl2br(linkifyHtml(item.response))" />
|
||||
</ResponseBlurb>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
|
||||
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
|
||||
import { ComponentWidget } from '@/scripts/domWidget'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const { widget, history = '[]' } = defineProps<{
|
||||
widget?: ComponentWidget<string>
|
||||
history: string
|
||||
}>()
|
||||
|
||||
const editIndex = ref<number | null>(null)
|
||||
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
|
||||
|
||||
const parsedHistory = computed(() => JSON.parse(history || '[]'))
|
||||
|
||||
const findPromptInput = () =>
|
||||
widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
let promptInput = findPromptInput()
|
||||
const previousPromptInput = ref<string | null>(null)
|
||||
|
||||
const getPreviousResponseId = (index: number) =>
|
||||
index > 0 ? parsedHistory.value[index - 1]?.response_id ?? '' : ''
|
||||
|
||||
const storePromptInput = () => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
if (!promptInput) return
|
||||
|
||||
previousPromptInput.value = String(promptInput.value)
|
||||
}
|
||||
|
||||
const setPromptInput = (text: string, previousResponseId?: string | null) => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
if (!promptInput) return
|
||||
|
||||
if (previousResponseId !== null) {
|
||||
promptInput.value = `<starting_point_id:${previousResponseId}>\n\n${text}`
|
||||
} else {
|
||||
promptInput.value = text
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (index: number) => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
editIndex.value = index
|
||||
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
|
||||
const promptText = parsedHistory.value[index]?.prompt ?? ''
|
||||
|
||||
storePromptInput()
|
||||
setPromptInput(promptText, prevResponseId)
|
||||
}
|
||||
|
||||
const resetEditingState = () => {
|
||||
editIndex.value = null
|
||||
}
|
||||
const handleCancelEdit = () => {
|
||||
resetEditingState()
|
||||
if (promptInput) {
|
||||
promptInput.value = previousPromptInput.value ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const scrollChatToBottom = () => {
|
||||
const content = document.getElementById('chat-scroll-content')
|
||||
if (content) {
|
||||
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const onHistoryChanged = () => {
|
||||
resetEditingState()
|
||||
void nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
watch(() => parsedHistory.value, onHistoryChanged, {
|
||||
immediate: true,
|
||||
deep: true
|
||||
})
|
||||
</script>
|
||||
@@ -11,6 +11,7 @@
|
||||
v-if="isComponentWidget(widget)"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
@update:model-value="emit('update:widgetValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
36
src/components/graph/widgets/chatHistory/CopyButton.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="!p-1 !h-4 !w-6 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
|
||||
pt:icon:class="!text-xs"
|
||||
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
|
||||
:aria-label="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { text } = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1024)
|
||||
}
|
||||
</script>
|
||||
22
src/components/graph/widgets/chatHistory/ResponseBlurb.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span>
|
||||
<div class="flex justify-start mb-1">
|
||||
<div class="rounded-xl px-4 py-1 max-w-[80%]">
|
||||
<div class="break-words text-[12px]">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start ml-1">
|
||||
<CopyButton :text="text" />
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -52,6 +52,9 @@ const eventConfig = {
|
||||
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
|
||||
backgroundImageChange: (value: string) =>
|
||||
emit('backgroundImageChange', value),
|
||||
backgroundImageLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
|
||||
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
upDirectionChange: (value: string) => emit('upDirectionChange', value),
|
||||
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
|
||||
modelLoadingStart: () =>
|
||||
@@ -73,9 +76,9 @@ const eventConfig = {
|
||||
emit('recordingStatusChange', value)
|
||||
} as const
|
||||
|
||||
watchEffect(async () => {
|
||||
watchEffect(() => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setBackgroundColor(props.backgroundColor)
|
||||
rawLoad3d.toggleGrid(props.showGrid)
|
||||
@@ -83,16 +86,36 @@ watchEffect(async () => {
|
||||
rawLoad3d.setFOV(props.fov)
|
||||
rawLoad3d.toggleCamera(props.cameraType)
|
||||
rawLoad3d.togglePreview(props.showPreview)
|
||||
await rawLoad3d.setBackgroundImage(props.backgroundImage)
|
||||
rawLoad3d.setUpDirection(props.upDirection)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.backgroundImage,
|
||||
async (newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
await rawLoad3d.setBackgroundImage(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setUpDirection(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setMaterialMode(newValue)
|
||||
}
|
||||
@@ -102,10 +125,9 @@ watch(
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
if (load3d.value && newValue) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
rawLoad3d.setEdgeThreshold(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
205
src/components/templates/TemplateWorkflowCard.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'AudioThumbnail',
|
||||
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
|
||||
props: ['src']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'CompareSliderThumbnail',
|
||||
template:
|
||||
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'DefaultThumbnail',
|
||||
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
|
||||
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'HoverDissolveThumbnail',
|
||||
template:
|
||||
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
|
||||
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementHover: () => ref(false)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: 'Test description',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const mountCard = (props = {}) => {
|
||||
return mount(TemplateWorkflowCard, {
|
||||
props: {
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
loading: false,
|
||||
template: createTemplate(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Card: {
|
||||
template:
|
||||
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
|
||||
props: ['dataTestid', 'pt']
|
||||
},
|
||||
ProgressSpinner: {
|
||||
template: '<div class="progress-spinner"></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ name: 'test-workflow' })
|
||||
})
|
||||
await wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
|
||||
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
|
||||
})
|
||||
|
||||
it('shows loading spinner when loading is true', () => {
|
||||
const wrapper = mountCard({ loading: true })
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders audio thumbnail for audio media type', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'audio' })
|
||||
})
|
||||
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders compare slider thumbnail for compareSlider variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'compareSlider' })
|
||||
})
|
||||
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
|
||||
})
|
||||
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders default thumbnail by default', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('passes correct props to default thumbnail for video', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ mediaType: 'video' })
|
||||
})
|
||||
const thumbnail = wrapper.find('.mock-default-thumbnail')
|
||||
expect(thumbnail.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses zoomHover scale when variant is zoomHover', () => {
|
||||
const wrapper = mountCard({
|
||||
template: createTemplate({ thumbnailVariant: 'zoomHover' })
|
||||
})
|
||||
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays localized title for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({ localizedTitle: 'My Localized Title' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Title')
|
||||
})
|
||||
|
||||
it('displays template name as title for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ name: 'custom-template' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom-template')
|
||||
})
|
||||
|
||||
it('displays localized description for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
localizedDescription: 'My Localized Description'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Localized Description')
|
||||
})
|
||||
|
||||
it('processes description for non-default source modules', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom',
|
||||
template: createTemplate({ description: 'custom_module-description' })
|
||||
})
|
||||
expect(wrapper.text()).toContain('custom module description')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for default source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'default',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'jpg'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
|
||||
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
|
||||
})
|
||||
|
||||
it('generates correct thumbnail URLs for custom source module', () => {
|
||||
const wrapper = mountCard({
|
||||
sourceModule: 'custom-module',
|
||||
template: createTemplate({
|
||||
name: 'my-template',
|
||||
mediaSubtype: 'png'
|
||||
})
|
||||
})
|
||||
const vm = wrapper.vm as any
|
||||
expect(vm.baseThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
expect(vm.overlayThumbnailSrc).toBe(
|
||||
'/apiURL/workflow_templates/custom-module/my-template.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
<Card
|
||||
ref="cardRef"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1 h-full"
|
||||
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
|
||||
:pt="{
|
||||
body: { class: 'p-0 h-full flex flex-col' }
|
||||
}"
|
||||
@@ -20,6 +20,10 @@
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
|
||||
@@ -28,6 +32,10 @@
|
||||
:overlay-image-src="overlayThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -35,6 +43,10 @@
|
||||
:src="baseThumbnailSrc"
|
||||
:alt="title"
|
||||
:is-hovered="isHovered"
|
||||
:is-video="
|
||||
template.mediaType === 'video' ||
|
||||
template.mediaSubtype === 'webp'
|
||||
"
|
||||
:hover-zoom="
|
||||
template.thumbnailVariant === 'zoomHover'
|
||||
? UPSCALE_ZOOM_SCALE
|
||||
@@ -52,18 +64,13 @@
|
||||
<template #content>
|
||||
<div class="flex items-center px-4 py-3">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-0 h-12" :title="title">
|
||||
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full"
|
||||
>
|
||||
<i class="pi pi-angle-right text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
133
src/components/templates/TemplateWorkflowView.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
vi.mock('primevue/dataview', () => ({
|
||||
default: {
|
||||
name: 'DataView',
|
||||
template: `
|
||||
<div class="p-dataview">
|
||||
<div class="dataview-header"><slot name="header"></slot></div>
|
||||
<div class="dataview-content">
|
||||
<slot name="grid" :items="value"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
props: ['value', 'layout', 'lazy', 'pt']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
template:
|
||||
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
|
||||
props: ['modelValue', 'options', 'allowEmpty']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<div
|
||||
class="mock-template-card"
|
||||
:data-name="template.name"
|
||||
:data-source-module="sourceModule"
|
||||
:data-category-title="categoryTitle"
|
||||
:data-loading="loading"
|
||||
@click="$emit('loadWorkflow', template.name)"
|
||||
></div>
|
||||
`,
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="mock-template-list"></div>',
|
||||
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
|
||||
emits: ['loadWorkflow']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: () => 'grid'
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowView', () => {
|
||||
const createTemplate = (name: string): TemplateInfo => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
thumbnailVariant: 'default',
|
||||
description: `Description for ${name}`
|
||||
})
|
||||
|
||||
const mountView = (props = {}) => {
|
||||
return mount(TemplateWorkflowView, {
|
||||
props: {
|
||||
title: 'Test Templates',
|
||||
sourceModule: 'default',
|
||||
categoryTitle: 'Test Category',
|
||||
templates: [
|
||||
createTemplate('template-1'),
|
||||
createTemplate('template-2'),
|
||||
createTemplate('template-3')
|
||||
],
|
||||
loading: null,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders template cards for each template', () => {
|
||||
const wrapper = mountView()
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
expect(cards.length).toBe(3)
|
||||
expect(cards[0].attributes('data-name')).toBe('template-1')
|
||||
expect(cards[1].attributes('data-name')).toBe('template-2')
|
||||
expect(cards[2].attributes('data-name')).toBe('template-3')
|
||||
})
|
||||
|
||||
it('emits loadWorkflow event when clicked', async () => {
|
||||
const wrapper = mountView()
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
|
||||
await card.trigger('click')
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
|
||||
// Check that the emitted event contains the template name
|
||||
const emitted = wrapper.emitted('loadWorkflow')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted?.[0][0]).toBe('template-1')
|
||||
})
|
||||
|
||||
it('passes correct props to template cards', () => {
|
||||
const wrapper = mountView({
|
||||
sourceModule: 'custom',
|
||||
categoryTitle: 'Custom Category'
|
||||
})
|
||||
|
||||
const card = wrapper.find('.mock-template-card')
|
||||
expect(card.exists()).toBe(true)
|
||||
expect(card.attributes('data-source-module')).toBe('custom')
|
||||
expect(card.attributes('data-category-title')).toBe('Custom Category')
|
||||
})
|
||||
|
||||
it('applies loading state correctly to cards', () => {
|
||||
const wrapper = mountView({
|
||||
loading: 'template-2'
|
||||
})
|
||||
|
||||
const cards = wrapper.findAll('.mock-template-card')
|
||||
|
||||
// Only the second card should have loading=true since loading="template-2"
|
||||
expect(cards[0].attributes('data-loading')).toBe('false')
|
||||
expect(cards[1].attributes('data-loading')).toBe('true')
|
||||
expect(cards[2].attributes('data-loading')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<template #grid="{ items }">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] auto-rows-fr gap-8 justify-items-center"
|
||||
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"
|
||||
|
||||
35
src/components/templates/thumbnails/AudioThumbnail.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseThumbnail',
|
||||
template: '<div class="base-thumbnail"><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
describe('AudioThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(AudioThumbnail, {
|
||||
props: {
|
||||
src: '/test-audio.mp3',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders an audio element with correct src', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const audio = wrapper.find('audio')
|
||||
expect(audio.exists()).toBe(true)
|
||||
expect(audio.attributes('src')).toBe('/test-audio.mp3')
|
||||
})
|
||||
|
||||
it('uses BaseThumbnail as container', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseThumbnail>
|
||||
<div class="w-64 h-64 flex items-center justify-center p-4">
|
||||
<div class="w-full h-full flex items-center justify-center p-4">
|
||||
<audio controls class="w-full relative" :src="src" @click.stop />
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
|
||||
66
src/components/templates/thumbnails/BaseThumbnail.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn()
|
||||
}))
|
||||
|
||||
describe('BaseThumbnail', () => {
|
||||
const mountThumbnail = (props = {}, slots = {}) => {
|
||||
return mount(BaseThumbnail, {
|
||||
props,
|
||||
slots: {
|
||||
default: '<img src="/test.jpg" alt="test" />',
|
||||
...slots
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applies hover zoom with correct style', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toContain('transform')
|
||||
expect(contentDiv.attributes('style')).toContain('scale')
|
||||
})
|
||||
|
||||
it('applies custom hover zoom value', () => {
|
||||
const wrapper = mountThumbnail({ hoverZoom: 10, isHovered: true })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toContain('scale(1.1)')
|
||||
})
|
||||
|
||||
it('does not apply scale when not hovered', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: false })
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.attributes('style')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const vm = wrapper.vm as any
|
||||
|
||||
// Manually set error since useEventListener is mocked
|
||||
vm.error = true
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.pi-file').exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies transition classes to content', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const contentDiv = wrapper.find('.transform-gpu')
|
||||
expect(contentDiv.classes()).toContain('transform-gpu')
|
||||
expect(contentDiv.classes()).toContain('transition-transform')
|
||||
expect(contentDiv.classes()).toContain('duration-1000')
|
||||
expect(contentDiv.classes()).toContain('ease-out')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
<div
|
||||
v-if="!error"
|
||||
ref="contentRef"
|
||||
class="w-64 h-64 object-cover transform-gpu transition-transform duration-1000 ease-out"
|
||||
class="w-full h-full transform-gpu transition-transform duration-1000 ease-out"
|
||||
:style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseThumbnail',
|
||||
template: '<div class="base-thumbnail"><slot /></div>',
|
||||
props: ['isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
elementWidth: ref(100),
|
||||
isOutside: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CompareSliderThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(CompareSliderThumbnail, {
|
||||
props: {
|
||||
baseImageSrc: '/base-image.jpg',
|
||||
overlayImageSrc: '/overlay-image.jpg',
|
||||
alt: 'Comparison Image',
|
||||
isVideo: false,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('applies clip-path style to overlay image', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const overlay = wrapper.findAll('img')[1]
|
||||
expect(overlay.attributes('style')).toContain('clip-path')
|
||||
})
|
||||
|
||||
it('renders slider divider', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('positions slider based on default value', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.attributes('style')).toContain('left: 21%')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.props('isHovered')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,23 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<img :src="baseImageSrc" :alt="alt" class="w-full h-full object-cover" />
|
||||
<img
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<img
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
class="w-full h-full object-cover"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
:style="{
|
||||
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
|
||||
}"
|
||||
@@ -28,13 +40,20 @@ import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 21
|
||||
|
||||
const { isHovered } = defineProps<{
|
||||
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
|
||||
baseImageSrc: string
|
||||
overlayImageSrc: string
|
||||
alt: string
|
||||
isHovered?: boolean
|
||||
isVideo?: boolean
|
||||
}>()
|
||||
|
||||
const isVideoType =
|
||||
isVideo ||
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
const sliderPosition = ref(SLIDER_START_POSITION)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
|
||||
102
src/components/templates/thumbnails/DefaultThumbnail.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseThumbnail',
|
||||
template: '<div class="base-thumbnail"><slot /></div>',
|
||||
props: ['hoverZoom', 'isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('DefaultThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(DefaultThumbnail, {
|
||||
props: {
|
||||
src: '/test-image.jpg',
|
||||
alt: 'Test Image',
|
||||
hoverZoom: 5,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('applies scale transform when hovered', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
isHovered: true,
|
||||
hoverZoom: 10
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('style')).toContain('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()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('applies correct styling for webp images', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
src: '/test-video.webp',
|
||||
isVideo: true
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
})
|
||||
|
||||
it('image is not draggable', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('draggable')).toBe('false')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('passes correct props to BaseThumbnail', () => {
|
||||
const wrapper = mountThumbnail({
|
||||
hoverZoom: 20,
|
||||
isHovered: true
|
||||
})
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.props('hoverZoom')).toBe(20)
|
||||
expect(baseThumbnail.props('isHovered')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
|
||||
<div class="overflow-hidden">
|
||||
<div class="overflow-hidden w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="w-64 h-64 object-cover transform-gpu transition-transform duration-300 ease-out"
|
||||
: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
|
||||
"
|
||||
@@ -17,10 +22,13 @@
|
||||
<script setup lang="ts">
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
defineProps<{
|
||||
const { src, isVideo } = defineProps<{
|
||||
src: string
|
||||
alt: string
|
||||
hoverZoom: number
|
||||
isHovered?: boolean
|
||||
isVideo?: boolean
|
||||
}>()
|
||||
|
||||
const isVideoType = isVideo ?? (src?.toLowerCase().endsWith('.webp') || false)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
|
||||
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseThumbnail',
|
||||
template: '<div class="base-thumbnail"><slot /></div>',
|
||||
props: ['isHovered']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('HoverDissolveThumbnail', () => {
|
||||
const mountThumbnail = (props = {}) => {
|
||||
return mount(HoverDissolveThumbnail, {
|
||||
props: {
|
||||
baseImageSrc: '/base-image.jpg',
|
||||
overlayImageSrc: '/overlay-image.jpg',
|
||||
alt: 'Dissolve Image',
|
||||
isHovered: false,
|
||||
isVideo: false,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
const wrapper = mountThumbnail({ isHovered: true })
|
||||
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
|
||||
expect(baseThumbnail.props('isHovered')).toBe(true)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('applies correct positioning to both images', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const images = wrapper.findAll('img')
|
||||
|
||||
// Check base image
|
||||
expect(images[0].classes()).toContain('absolute')
|
||||
expect(images[0].classes()).toContain('inset-0')
|
||||
|
||||
// Check overlay image
|
||||
expect(images[1].classes()).toContain('absolute')
|
||||
expect(images[1].classes()).toContain('inset-0')
|
||||
})
|
||||
})
|
||||
@@ -5,14 +5,24 @@
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 w-64 h-64 object-cover"
|
||||
class="absolute inset-0"
|
||||
:class="
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<img
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 w-64 h-64 object-cover transition-opacity duration-300"
|
||||
:class="{ 'opacity-100': isHovered, 'opacity-0': !isHovered }"
|
||||
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 }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</BaseThumbnail>
|
||||
@@ -21,10 +31,17 @@
|
||||
<script setup lang="ts">
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
defineProps<{
|
||||
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
|
||||
baseImageSrc: string
|
||||
overlayImageSrc: string
|
||||
alt: string
|
||||
isHovered: boolean
|
||||
isVideo?: boolean
|
||||
}>()
|
||||
|
||||
const isVideoType =
|
||||
isVideo ||
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
</script>
|
||||
|
||||
122
src/components/topbar/CurrentUserButton.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
isLoggedIn: true,
|
||||
userPhotoUrl: 'https://example.com/avatar.jpg',
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com'
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
render() {
|
||||
return h('div', 'Avatar')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopover component
|
||||
vi.mock('./CurrentUserPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverMock',
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
emits: ['close']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
// Use shallow mount for popover to make testing easier
|
||||
Popover: {
|
||||
template: '<div><slot></slot></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders correctly when user is logged in', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles popover on button click', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const popoverToggleSpy = vi.fn()
|
||||
|
||||
// Override the ref with a mock implementation
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { toggle: popoverToggleSpy }
|
||||
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides popover when closePopover is called', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Replace the popover.hide method with a spy
|
||||
const popoverHideSpy = vi.fn()
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { hide: popoverHideSpy }
|
||||
|
||||
// Directly call the closePopover method through the component instance
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.closePopover()
|
||||
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@
|
||||
</Button>
|
||||
|
||||
<Popover ref="popover" :show-arrow="false">
|
||||
<CurrentUserPopover />
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,4 +40,8 @@ const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
173
src/components/topbar/CurrentUserPopover.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
userPhotoUrl: 'https://example.com/avatar.jpg',
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com'
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useFirebaseAuthActions composable
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSettingsDialog: mockShowSettingsDialog,
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
render() {
|
||||
return h('div', 'Avatar')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopover', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserPopover, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true,
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders user information correctly', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the settings button (first one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[0]
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
|
||||
// Verify showSettingsDialog was called with 'user'
|
||||
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the API pricing button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[1]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/pricing',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the top-up button (last one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[buttons.length - 1]
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
// Verify showTopUpCreditsDialog was called
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -69,26 +69,33 @@ import { onMounted } from 'vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<div class="w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
310
src/composables/README.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Composables
|
||||
|
||||
This directory contains Vue composables for the ComfyUI frontend application. Composables are reusable pieces of logic that encapsulate stateful functionality and can be shared across components.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Composable Architecture](#composable-architecture)
|
||||
- [Composable Categories](#composable-categories)
|
||||
- [Usage Guidelines](#usage-guidelines)
|
||||
- [VueUse Library](#vueuse-library)
|
||||
- [Development Guidelines](#development-guidelines)
|
||||
- [Common Patterns](#common-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Vue composables are a core part of Vue 3's Composition API and provide a way to extract and reuse stateful logic between multiple components. In ComfyUI, composables are used to encapsulate behaviors like:
|
||||
|
||||
- State management
|
||||
- DOM interactions
|
||||
- Feature-specific functionality
|
||||
- UI behaviors
|
||||
- Data fetching
|
||||
|
||||
Composables enable a more modular and functional approach to building components, allowing for better code reuse and separation of concerns. They help keep your component code cleaner by extracting complex logic into separate, reusable functions.
|
||||
|
||||
As described in the [Vue.js documentation](https://vuejs.org/guide/reusability/composables.html), composables are:
|
||||
> Functions that leverage Vue's Composition API to encapsulate and reuse stateful logic.
|
||||
|
||||
## Composable Architecture
|
||||
|
||||
The composable architecture in ComfyUI follows these principles:
|
||||
|
||||
1. **Single Responsibility**: Each composable should focus on a specific concern
|
||||
2. **Composition**: Composables can use other composables
|
||||
3. **Reactivity**: Composables leverage Vue's reactivity system
|
||||
4. **Reusability**: Composables are designed to be used across multiple components
|
||||
|
||||
The following diagram shows how composables fit into the application architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Component A │ │ Component B │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │
|
||||
└────────────┼───────────────────┼────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┴───────────────────┴────────────────────────┐
|
||||
│ Composables │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ useFeatureA │ │ useFeatureB │ │ useFeatureC │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼────────────────┼────────────────┼─────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┴────────────────┴────────────────┴─────────────┐
|
||||
│ Services & Stores │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Composable Categories
|
||||
|
||||
ComfyUI's composables are organized into several categories:
|
||||
|
||||
### Auth
|
||||
|
||||
Composables for authentication and user management:
|
||||
- `useCurrentUser` - Provides access to the current user information
|
||||
- `useFirebaseAuthActions` - Handles Firebase authentication operations
|
||||
|
||||
### Element
|
||||
|
||||
Composables for DOM and element interactions:
|
||||
- `useAbsolutePosition` - Handles element positioning
|
||||
- `useDomClipping` - Manages clipping of DOM elements
|
||||
- `useResponsiveCollapse` - Manages responsive collapsing of elements
|
||||
|
||||
### Node
|
||||
|
||||
Composables for node-specific functionality:
|
||||
- `useNodeBadge` - Handles node badge display and interaction
|
||||
- `useNodeImage` - Manages node image preview
|
||||
- `useNodeDragAndDrop` - Handles drag and drop for nodes
|
||||
- `useNodeChatHistory` - Manages chat history for nodes
|
||||
|
||||
### Settings
|
||||
|
||||
Composables for settings management:
|
||||
- `useSettingSearch` - Provides search functionality for settings
|
||||
- `useSettingUI` - Manages settings UI interactions
|
||||
|
||||
### Sidebar
|
||||
|
||||
Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
|
||||
### Widgets
|
||||
|
||||
Composables for widget functionality:
|
||||
- `useBooleanWidget` - Manages boolean widget interactions
|
||||
- `useComboWidget` - Manages combo box widget interactions
|
||||
- `useFloatWidget` - Manages float input widget interactions
|
||||
- `useImagePreviewWidget` - Manages image preview widget
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
When using composables in components, follow these guidelines:
|
||||
|
||||
1. **Import and call** composables at the top level of the `setup` function
|
||||
2. **Destructure returned values** to use in your component
|
||||
3. **Respect reactivity** by not destructuring reactive objects
|
||||
4. **Handle cleanup** by using `onUnmounted` when necessary
|
||||
5. **Use VueUse** for common functionality instead of writing from scratch
|
||||
|
||||
Example usage:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'dragging': isDragging }"
|
||||
@mousedown="startDrag"
|
||||
@mouseup="endDrag"
|
||||
>
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Node preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop';
|
||||
import { useNodeImage } from '@/composables/node/useNodeImage';
|
||||
|
||||
// Use composables at the top level
|
||||
const { isDragging, startDrag, endDrag } = useNodeDragAndDrop();
|
||||
const { imageUrl, loadImage } = useNodeImage();
|
||||
|
||||
// Use returned values in your component
|
||||
</script>
|
||||
```
|
||||
|
||||
## VueUse Library
|
||||
|
||||
ComfyUI leverages the [VueUse](https://vueuse.org/) library, which provides a collection of essential Vue Composition API utilities. Instead of implementing common functionality from scratch, prefer using VueUse composables for:
|
||||
|
||||
- DOM event handling (`useEventListener`, `useMouseInElement`)
|
||||
- Element measurements (`useElementBounding`, `useElementSize`)
|
||||
- Asynchronous operations (`useAsyncState`, `useFetch`)
|
||||
- Animation and timing (`useTransition`, `useTimeout`, `useInterval`)
|
||||
- Browser APIs (`useLocalStorage`, `useClipboard`)
|
||||
- Sensors (`useDeviceMotion`, `useDeviceOrientation`)
|
||||
- State management (`createGlobalState`, `useStorage`)
|
||||
- ...and [more](https://vueuse.org/functions.html)
|
||||
|
||||
Examples:
|
||||
|
||||
```js
|
||||
// Instead of manually adding/removing event listeners
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
useEventListener(window, 'resize', handleResize)
|
||||
|
||||
// Instead of manually tracking element measurements
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
|
||||
const { width, height, top, left } = useElementBounding(elementRef)
|
||||
|
||||
// Instead of manual async state management
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
|
||||
const { state, isReady, isLoading } = useAsyncState(
|
||||
fetch('https://api.example.com/data').then(r => r.json()),
|
||||
{ data: [] }
|
||||
)
|
||||
```
|
||||
|
||||
For a complete list of available functions, see the [VueUse documentation](https://vueuse.org/functions.html).
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
When creating or modifying composables, follow these best practices:
|
||||
|
||||
1. **Name with `use` prefix**: All composables should start with "use"
|
||||
2. **Return an object**: Composables should return an object with named properties/methods
|
||||
3. **Handle cleanup**: Use `onUnmounted` to clean up resources
|
||||
4. **Document parameters and return values**: Add JSDoc comments
|
||||
5. **Test composables**: Write unit tests for composable functionality
|
||||
6. **Use VueUse**: Leverage VueUse composables instead of reimplementing common functionality
|
||||
7. **Implement proper cleanup**: Cancel debounced functions, pending requests, and clear maps
|
||||
8. **Use watchDebounced/watchThrottled**: For performance-sensitive reactive operations
|
||||
|
||||
### Composable Template
|
||||
|
||||
Here's a template for creating a new composable:
|
||||
|
||||
```typescript
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable for [functionality description]
|
||||
* @param options Configuration options
|
||||
* @returns Object containing state and methods
|
||||
*/
|
||||
export function useExample(options = {}) {
|
||||
// State
|
||||
const state = ref({
|
||||
// Initial state
|
||||
});
|
||||
|
||||
// Computed values
|
||||
const derivedValue = computed(() => {
|
||||
// Compute from state
|
||||
return state.value.someProperty;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function doSomething() {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Setup
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup
|
||||
});
|
||||
|
||||
// Return exposed state and methods
|
||||
return {
|
||||
state,
|
||||
derivedValue,
|
||||
doSomething
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Composables in ComfyUI frequently use these patterns:
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
export function useState() {
|
||||
const count = ref(0);
|
||||
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
increment
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling with VueUse
|
||||
|
||||
```typescript
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
export function useKeyPress(key) {
|
||||
const isPressed = ref(false);
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('keyup', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isPressed };
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch & Load with VueUse
|
||||
|
||||
```typescript
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
|
||||
export function useFetchData(url) {
|
||||
const { state: data, isLoading, error, execute: refresh } = useAsyncState(
|
||||
async () => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch data');
|
||||
return response.json();
|
||||
},
|
||||
null,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { data, isLoading, error, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
@@ -11,11 +12,13 @@ import { usdToMicros } from '@/utils/formatUtil'
|
||||
* All actions are wrapped with error handling.
|
||||
* @returns {Object} - Object containing all Firebase Auth actions
|
||||
*/
|
||||
export const useFirebaseAuthService = () => {
|
||||
export const useFirebaseAuthActions = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const accessError = ref(false)
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
// Ref: https://firebase.google.com/docs/auth/admin/errors
|
||||
if (
|
||||
@@ -26,6 +29,7 @@ export const useFirebaseAuthService = () => {
|
||||
'auth/unauthorized-continue-uri'
|
||||
].includes(error.code)
|
||||
) {
|
||||
accessError.value = true
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
@@ -141,6 +145,7 @@ export const useFirebaseAuthService = () => {
|
||||
signInWithGithub,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
updatePassword
|
||||
updatePassword,
|
||||
accessError
|
||||
}
|
||||
}
|
||||
147
src/composables/node/useNodeBadge.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
BadgePosition,
|
||||
LGraphBadge,
|
||||
type LGraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
/**
|
||||
* Add LGraphBadge to LGraphNode based on settings.
|
||||
*
|
||||
* Following badges are added:
|
||||
* - Node ID badge
|
||||
* - Node source badge
|
||||
* - Node life cycle badge
|
||||
* - API node credits badge
|
||||
*/
|
||||
export const useNodeBadge = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const extensionStore = useExtensionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const nodeSourceBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
|
||||
) as NodeBadgeMode
|
||||
)
|
||||
|
||||
const showApiPricingBadge = computed(() =>
|
||||
settingStore.get('Comfy.NodeBadge.ShowApiPricing')
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
nodeSourceBadgeMode,
|
||||
nodeIdBadgeMode,
|
||||
nodeLifeCycleBadgeMode,
|
||||
showApiPricingBadge
|
||||
],
|
||||
() => {
|
||||
app.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function badgeTextVisible(
|
||||
nodeDef: ComfyNodeDefImpl | null,
|
||||
badgeMode: NodeBadgeMode
|
||||
): boolean {
|
||||
return !(
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
return new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
|
||||
? `#${node.id}`
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
|
||||
? nodeDef?.nodeLifeCycleBadgeText ?? ''
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
|
||||
? nodeDef?.nodeSource?.badgeText ?? ''
|
||||
: ''
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
// Always add the badge for API nodes, with or without price text
|
||||
const creditsBadge = computed(() => {
|
||||
// Use dynamic background color based on the theme
|
||||
const isLightTheme =
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
text: price,
|
||||
iconOptions: {
|
||||
unicode: '\ue96b',
|
||||
fontFamily: 'PrimeIcons',
|
||||
color: isLightTheme
|
||||
? adjustColor('#FABC25', { lightness: 0.5 })
|
||||
: '#FABC25',
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#654020', { lightness: 0.5 })
|
||||
: '#654020',
|
||||
fontSize: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
59
src/composables/node/useNodeChatHistory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
|
||||
|
||||
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
|
||||
|
||||
/**
|
||||
* Composable for handling node text previews
|
||||
*/
|
||||
export function useNodeChatHistory(
|
||||
options: {
|
||||
minHeight?: number
|
||||
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
|
||||
} = {}
|
||||
) {
|
||||
const chatHistoryWidget = useChatHistoryWidget(options)
|
||||
|
||||
const addChatHistoryWidget = (node: LGraphNode) =>
|
||||
chatHistoryWidget(node, {
|
||||
name: CHAT_HISTORY_WIDGET_NAME,
|
||||
type: 'chatHistory'
|
||||
})
|
||||
|
||||
/**
|
||||
* Shows chat history for a node
|
||||
* @param node The graph node to show the chat history for
|
||||
*/
|
||||
function showChatHistory(node: LGraphNode) {
|
||||
// First remove any existing widget
|
||||
removeChatHistory(node)
|
||||
|
||||
// Then add the widget with new history
|
||||
addChatHistoryWidget(node)
|
||||
node.setDirtyCanvas?.(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes chat history from a node
|
||||
* @param node The graph node to remove the chat history from
|
||||
*/
|
||||
function removeChatHistory(node: LGraphNode) {
|
||||
if (!node.widgets) return
|
||||
|
||||
const widgetIdx = node.widgets.findIndex(
|
||||
(w) => w.name === CHAT_HISTORY_WIDGET_NAME
|
||||
)
|
||||
|
||||
if (widgetIdx > -1) {
|
||||
node.widgets[widgetIdx].onRemove?.()
|
||||
node.widgets.splice(widgetIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showChatHistory,
|
||||
removeChatHistory
|
||||
}
|
||||
}
|
||||
426
src/composables/node/useNodePricing.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
import { ApiNodeCostRecord } from '@/types/apiNodeTypes'
|
||||
|
||||
const apiNodeCosts: ApiNodeCostRecord = {
|
||||
FluxProCannyNode: {
|
||||
vendor: 'BFL',
|
||||
nodeName: 'Flux 1: Canny Control Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.05',
|
||||
displayPrice: '$0.05/Run'
|
||||
},
|
||||
FluxProDepthNode: {
|
||||
vendor: 'BFL',
|
||||
nodeName: 'Flux 1: Depth Control Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.05',
|
||||
displayPrice: '$0.05/Run'
|
||||
},
|
||||
FluxProExpandNode: {
|
||||
vendor: 'BFL',
|
||||
nodeName: 'Flux 1: Expand Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.05',
|
||||
rateDocumentationUrl: 'https://docs.bfl.ml/pricing/',
|
||||
displayPrice: '$0.05/Run'
|
||||
},
|
||||
FluxProFillNode: {
|
||||
vendor: 'BFL',
|
||||
nodeName: 'Flux 1: Fill Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.05',
|
||||
displayPrice: '$0.05/Run'
|
||||
},
|
||||
FluxProUltraImageNode: {
|
||||
vendor: 'BFL',
|
||||
nodeName: 'Flux 1.1: [pro] Ultra Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.06',
|
||||
displayPrice: '$0.06/Run'
|
||||
},
|
||||
IdeogramV1: {
|
||||
vendor: 'Ideogram',
|
||||
nodeName: 'Ideogram V1',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.06',
|
||||
rateDocumentationUrl: 'https://about.ideogram.ai/api-pricing',
|
||||
displayPrice: '$0.06/Run'
|
||||
},
|
||||
IdeogramV2: {
|
||||
vendor: 'Ideogram',
|
||||
nodeName: 'Ideogram V2',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.08',
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
IdeogramV3: {
|
||||
vendor: 'Ideogram',
|
||||
nodeName: 'Ideogram V3',
|
||||
pricingParams: 'rendering_speed',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (low to medium)'
|
||||
},
|
||||
KlingCameraControlI2VNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Image to Video (Camera Control)',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.49',
|
||||
displayPrice: '$0.49/Run'
|
||||
},
|
||||
KlingCameraControlT2VNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Text to Video (Camera Control)',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.14',
|
||||
displayPrice: '$0.14/Run'
|
||||
},
|
||||
KlingDualCharacterVideoEffectNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Dual Character Video Effects',
|
||||
pricingParams: 'Priced the same as t2v based on mode, model, and duration.',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
KlingImage2VideoNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Image to Video',
|
||||
pricingParams: 'Same as Text to Video',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
KlingImageGenerationNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Image Generation',
|
||||
pricingParams: 'modality | model',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (low)'
|
||||
},
|
||||
KlingLipSyncAudioToVideoNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Lip Sync Video with Audio',
|
||||
pricingParams: 'duration of input video',
|
||||
pricePerRunRange: '$0.07',
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingLipSyncTextToVideoNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Lip Sync Video with Text',
|
||||
pricingParams: 'duration of input video',
|
||||
pricePerRunRange: '$0.07',
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
KlingSingleImageVideoEffectNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Video Effects',
|
||||
pricingParams: 'effect_scene',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
KlingStartEndFrameNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Start-End Frame to Video',
|
||||
pricingParams: 'Same as text to video',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
KlingTextToVideoNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Text to Video',
|
||||
pricingParams: 'model | duration | mode',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium to high)'
|
||||
},
|
||||
KlingVideoExtendNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Video Extend',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.28',
|
||||
displayPrice: '$0.28/Run'
|
||||
},
|
||||
KlingVirtualTryOnNode: {
|
||||
vendor: 'Kling',
|
||||
nodeName: 'Kling Virtual Try On',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.07',
|
||||
displayPrice: '$0.07/Run'
|
||||
},
|
||||
LumaImageToVideoNode: {
|
||||
vendor: 'Luma',
|
||||
nodeName: 'Luma Image to Video',
|
||||
pricingParams: 'Same as Text to Video',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
|
||||
displayPrice: 'Variable pricing (medium to high)'
|
||||
},
|
||||
LumaVideoNode: {
|
||||
vendor: 'Luma',
|
||||
nodeName: 'Luma Text to Video',
|
||||
pricingParams: 'model | resolution | duration',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
|
||||
displayPrice: 'Variable pricing (medium to high)'
|
||||
},
|
||||
MinimaxImageToVideoNode: {
|
||||
vendor: 'Minimax',
|
||||
nodeName: 'MiniMax Image to Video',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.43',
|
||||
rateDocumentationUrl: 'https://www.minimax.io/price',
|
||||
displayPrice: '$0.43/Run'
|
||||
},
|
||||
MinimaxTextToVideoNode: {
|
||||
vendor: 'Minimax',
|
||||
nodeName: 'MiniMax Text to Video',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.43',
|
||||
rateDocumentationUrl: 'https://www.minimax.io/price',
|
||||
displayPrice: '$0.43/Run'
|
||||
},
|
||||
OpenAIDalle2: {
|
||||
vendor: 'OpenAI',
|
||||
nodeName: 'dall-e-2',
|
||||
pricingParams: 'size',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
|
||||
displayPrice: 'Variable pricing (low)'
|
||||
},
|
||||
OpenAIDalle3: {
|
||||
vendor: 'OpenAI',
|
||||
nodeName: 'dall-e-3',
|
||||
pricingParams: 'size | quality',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
OpenAIGPTImage1: {
|
||||
vendor: 'OpenAI',
|
||||
nodeName: 'gpt-image-1',
|
||||
pricingParams: 'quality',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
|
||||
displayPrice: 'Variable pricing (low to high)'
|
||||
},
|
||||
PikaImageToVideoNode2_2: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pika Image to Video',
|
||||
pricingParams: 'duration | resolution',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
PikaScenesV2_2: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pika Scenes (Video Image Composition)',
|
||||
pricingParams: 'duration | resolution',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
PikaStartEndFrameNode2_2: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pika Start and End Frame to Video',
|
||||
pricingParams: 'duration | resolution',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
PikaTextToVideoNode2_2: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pika Text to Video',
|
||||
pricingParams: 'duration | resolution',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium)'
|
||||
},
|
||||
Pikadditions: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pikadditions (Video Object Insertion)',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.3',
|
||||
displayPrice: '$0.3/Run'
|
||||
},
|
||||
Pikaffects: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pikaffects (Video Effects)',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.45',
|
||||
displayPrice: '$0.45/Run'
|
||||
},
|
||||
Pikaswaps: {
|
||||
vendor: 'Pika',
|
||||
nodeName: 'Pika Swaps (Video Object Replacement)',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.3',
|
||||
displayPrice: '$0.3/Run'
|
||||
},
|
||||
PixverseImageToVideoNode: {
|
||||
vendor: 'Pixverse',
|
||||
nodeName: 'PixVerse Image to Video',
|
||||
pricingParams: 'same as text to video',
|
||||
pricePerRunRange: '$0.9',
|
||||
displayPrice: '$0.9/Run'
|
||||
},
|
||||
PixverseTextToVideoNode: {
|
||||
vendor: 'Pixverse',
|
||||
nodeName: 'PixVerse Text to Video',
|
||||
pricingParams: 'duration | quality | motion_mode',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (medium to high)'
|
||||
},
|
||||
PixverseTransitionVideoNode: {
|
||||
vendor: 'Pixverse',
|
||||
nodeName: 'PixVerse Transition Video',
|
||||
pricingParams: 'same as text to video',
|
||||
pricePerRunRange: '$0.9',
|
||||
displayPrice: '$0.9/Run'
|
||||
},
|
||||
RecraftCreativeUpscaleNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Creative Upscale Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.25',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.25/Run'
|
||||
},
|
||||
RecraftCrispUpscaleNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Crisp Upscale Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.004',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.004/Run'
|
||||
},
|
||||
RecraftImageInpaintingNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Image Inpainting',
|
||||
pricingParams: 'n',
|
||||
pricePerRunRange: '$$0.04 x n',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.04 x n/Run'
|
||||
},
|
||||
RecraftImageToImageNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Image to Image',
|
||||
pricingParams: 'n',
|
||||
pricePerRunRange: '$0.04 x n',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.04 x n/Run'
|
||||
},
|
||||
RecraftRemoveBackgroundNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Remove Background',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.01',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.01/Run'
|
||||
},
|
||||
RecraftReplaceBackgroundNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Replace Background',
|
||||
pricingParams: 'n',
|
||||
pricePerRunRange: '$0.04',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.04/Run'
|
||||
},
|
||||
RecraftTextToImageNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Text to Image',
|
||||
pricingParams: 'model | n',
|
||||
pricePerRunRange: '$0.04 x n',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.04 x n/Run'
|
||||
},
|
||||
RecraftTextToVectorNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Text to Vector',
|
||||
pricingParams: 'model | n',
|
||||
pricePerRunRange: '$0.08 x n',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.08 x n/Run'
|
||||
},
|
||||
RecraftVectorizeImageNode: {
|
||||
vendor: 'Recraft',
|
||||
nodeName: 'Recraft Vectorize Image',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.01',
|
||||
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
|
||||
displayPrice: '$0.01/Run'
|
||||
},
|
||||
StabilityStableImageSD_3_5Node: {
|
||||
vendor: 'Stability',
|
||||
nodeName: 'Stability AI Stable Diffusion 3.5 Image',
|
||||
pricingParams: 'model',
|
||||
pricePerRunRange: 'dynamic',
|
||||
displayPrice: 'Variable pricing (low)'
|
||||
},
|
||||
StabilityStableImageUltraNode: {
|
||||
vendor: 'Stability',
|
||||
nodeName: 'Stability AI Stable Image Ultra',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.08',
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
StabilityUpscaleConservativeNode: {
|
||||
vendor: 'Stability',
|
||||
nodeName: 'Stability AI Upscale Conservative',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.25',
|
||||
displayPrice: '$0.25/Run'
|
||||
},
|
||||
StabilityUpscaleCreativeNode: {
|
||||
vendor: 'Stability',
|
||||
nodeName: 'Stability AI Upscale Creative',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.25',
|
||||
displayPrice: '$0.25/Run'
|
||||
},
|
||||
StabilityUpscaleFastNode: {
|
||||
vendor: 'Stability',
|
||||
nodeName: 'Stability AI Upscale Fast',
|
||||
pricingParams: '-',
|
||||
pricePerRunRange: '$0.01',
|
||||
displayPrice: '$0.01/Run'
|
||||
},
|
||||
VeoVideoGenerationNode: {
|
||||
vendor: 'Veo',
|
||||
nodeName: 'Google Veo2 Video Generation',
|
||||
pricingParams: 'duration_seconds',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl:
|
||||
'https://cloud.google.com/vertex-ai/generative-ai/pricing',
|
||||
displayPrice: 'Variable pricing (high)'
|
||||
},
|
||||
LumaTextToImageNode: {
|
||||
vendor: 'Luma',
|
||||
nodeName: 'Luma Text to Image',
|
||||
pricingParams: 'model | aspect_ratio',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
|
||||
displayPrice: 'Variable pricing (low to medium)'
|
||||
},
|
||||
LumaImageToImageNode: {
|
||||
vendor: 'Luma',
|
||||
nodeName: 'Luma Image to Image',
|
||||
pricingParams: 'Same as Text to Image',
|
||||
pricePerRunRange: 'dynamic',
|
||||
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
|
||||
displayPrice: 'Variable pricing (low to medium)'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to get node pricing information for API nodes
|
||||
*/
|
||||
export const useNodePricing = () => {
|
||||
const getNodePrice = (nodeName: string): string =>
|
||||
apiNodeCosts[nodeName]?.displayPrice || ''
|
||||
|
||||
/**
|
||||
* Get the price display for a node
|
||||
*/
|
||||
const getNodeDisplayPrice = (node: LGraphNode): string => {
|
||||
if (!node.constructor.nodeData?.api_node) return ''
|
||||
return getNodePrice(node.constructor.nodeData.name)
|
||||
}
|
||||
|
||||
return {
|
||||
getNodeDisplayPrice
|
||||
}
|
||||
}
|
||||
53
src/composables/useBrowserTabTitle.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
export const useBrowserTabTitle = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle
|
||||
? ''
|
||||
: `[${Math.round(executionStore.executionProgress * 100)}%]`
|
||||
)
|
||||
|
||||
const newMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const isUnsavedText = computed(() =>
|
||||
workflowStore.activeWorkflow?.isModified ||
|
||||
!workflowStore.activeWorkflow?.isPersisted
|
||||
? ' *'
|
||||
: ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
return workflowName
|
||||
? isUnsavedText.value + workflowName + TITLE_SUFFIX
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
executionText.value +
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
useTitle(title)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
@@ -13,11 +14,10 @@ import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -32,8 +32,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthService = useFirebaseAuthService()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -320,7 +321,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
const queueNodeIds = getSelectedNodes()
|
||||
.filter((node) => node.constructor.nodeData.output_node)
|
||||
.filter((node) => node.constructor.nodeData?.output_node)
|
||||
.map((node) => node.id)
|
||||
if (queueNodeIds.length === 0) {
|
||||
toastStore.add({
|
||||
@@ -671,7 +672,31 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Sign Out',
|
||||
versionAdded: '1.18.1',
|
||||
function: async () => {
|
||||
await firebaseAuthService.logout()
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -128,4 +128,10 @@ export const useLitegraphSettings = () => {
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.saveViewportWithGraph = settingStore.get(
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
23
src/composables/useProgressFavicon.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useFavicon } from '@vueuse/core'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export const useProgressFavicon = () => {
|
||||
const defaultFavicon = '/assets/images/favicon_progress_16x16/frame_9.png'
|
||||
const favicon = useFavicon(defaultFavicon)
|
||||
const executionStore = useExecutionStore()
|
||||
const totalFrames = 10
|
||||
|
||||
watch(
|
||||
[() => executionStore.executionProgress, () => executionStore.isIdle],
|
||||
([progress, isIdle]) => {
|
||||
if (isIdle) {
|
||||
favicon.value = defaultFavicon
|
||||
} else {
|
||||
const frame = Math.floor(progress * totalFrames)
|
||||
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
||||
import { memoize, orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
AlgoliaNodePack,
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 256
|
||||
const SEARCH_DEBOUNCE_TIME = 320
|
||||
const DEFAULT_PAGE_SIZE = 64
|
||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
|
||||
[SortableAlgoliaField.Downloads]: 'desc',
|
||||
[SortableAlgoliaField.Created]: 'desc',
|
||||
@@ -30,7 +30,12 @@ const isDateField = (field: SortableAlgoliaField): boolean =>
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch() {
|
||||
export function useRegistrySearch(
|
||||
options: {
|
||||
maxCacheSize?: number
|
||||
} = {}
|
||||
) {
|
||||
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
|
||||
const searchMode = ref<'nodes' | 'packs'>('packs')
|
||||
@@ -56,7 +61,10 @@ export function useRegistrySearch() {
|
||||
: []
|
||||
)
|
||||
|
||||
const { searchPacks, toRegistryPack } = useAlgoliaSearchService()
|
||||
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
|
||||
useAlgoliaSearchService({
|
||||
maxCacheSize
|
||||
})
|
||||
|
||||
const algoliaToRegistry = memoize(
|
||||
toRegistryPack,
|
||||
@@ -77,7 +85,7 @@ export function useRegistrySearch() {
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
@@ -116,6 +124,8 @@ export function useRegistrySearch() {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
onUnmounted(clearSearchPacksCache)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
pageNumber,
|
||||
|
||||