Compare commits

...

107 Commits

Author SHA1 Message Date
Johnpaul
dd9680bac8 fix: remove VRAM sorting option from template filtering 2025-09-26 18:53:25 +01:00
Jin Yi
f44356b152 fix: workflow template style review 2025-09-26 15:20:51 +09:00
Johnpaul
a5100ba37a fix: add key for template list re-rendering on sorting changes 2025-09-25 02:41:45 +01:00
Johnpaul Chiwetelu
939254b1f2 Merge branch 'main' into feat/new-workflow-templates 2025-09-24 20:08:40 +01:00
filtered
b0f81b2245 Rework desktop install / startup UX (#5292)
### Summary

Complete redesign of Desktop app installer, implementing the new app
startup progress system and error reporting.
2025-09-24 12:06:58 -07:00
Johnpaul
65bb4c695d fix: update 'Getting Started' label to reflect essential category title 2025-09-24 19:39:56 +01:00
Johnpaul
a3adaf2998 fix: remove unused SquareChip templates from WorkflowTemplateSelectorDialog 2025-09-24 19:39:56 +01:00
github-actions
77bb1e4928 Update test expectations [skip ci] 2025-09-24 05:35:08 +00:00
Johnpaul
7676ebfed0 fix e2e tests 2025-09-24 06:07:56 +01:00
Alexander Brown
6449d26cee cleanup: remove useCanvasTransformSync composables. (#5742)
No I am not proud of the new placeholder arguments.

## Summary

Small change to unify the two composables before updating the logic.
Edit: Now unifies them both into the **void**.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5742-cleanup-unify-useCanvasTransformSync-composables-2776d73d36508147ad39d11de8588b2e)
by [Unito](https://www.unito.io)
2025-09-23 21:36:29 -07:00
Tristan Sommer
80cabc61ee fix: maskeditor - fixed color select and paint bucket settings not showing up (#5733)
## Summary

color select settings and paint bucket settings were not showing up -
fixed that

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5733-fix-maskeditor-fixed-color-select-and-paint-bucket-settings-not-showing-up-2776d73d365081e6be2ddc7784ab8535)
by [Unito](https://www.unito.io)
2025-09-23 16:27:16 -07:00
Arjan Singh
76dd935b35 [fix] use object-contain for image preview (#5739)
## Summary

Change css so preview images are fully contained within the image
preview component

## Screenshots (if applicable)

### Before

<img width="453" height="640" alt="Screenshot 2025-09-23 at 2 55 56 PM"
src="https://github.com/user-attachments/assets/892c7fd4-b9d9-4fdd-9846-d480c5aeb6be"
/>

### After

<img width="523" height="673" alt="Screenshot 2025-09-23 at 2 55 26 PM"
src="https://github.com/user-attachments/assets/ca3f4823-9d57-4bf4-93cc-492652ee9631"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5739-fix-use-object-contain-for-image-preview-2776d73d365081f3b77ce498bd8799ec)
by [Unito](https://www.unito.io)
2025-09-23 16:04:54 -07:00
Alexander Brown
f7f3240100 fix: Status indicator and close button appearing together (#5738)
## Summary

Small fix for the close/status visibility overlap

<img width="392" height="128" alt="image"
src="https://github.com/user-attachments/assets/af25f1d7-a8c3-4155-9123-9fa10724e8db"
/>

![20250923-2140-26
4569642](https://github.com/user-attachments/assets/e1b00a3f-d6e9-416b-9014-df0f9241082e)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5738-fix-Status-indicator-and-close-button-appearing-together-2776d73d3650813e9601e519c8a85043)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-23 15:23:00 -07:00
Simula_r
e4022c455a fix: add LODFallback to markdown widget (#5734)
## Summary

Add LOD fallback to markdown widget

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5734-fix-add-LODFallback-to-markdown-widget-2776d73d36508128b923d45beab50f03)
by [Unito](https://www.unito.io)

---------

Co-authored-by: JakeSchroeder <jake@axiom.co>
2025-09-23 13:05:15 -07:00
filtered
c6e50d8f1b Fix overlapping elements in desktop installer (#5735)
Fixes regression from Tailwind v4 upgrade where CPU toggle overlaps
content.

Removed unnecessary translate transform causing element positioning
issues.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5735-Fix-overlapping-elements-in-desktop-installer-2776d73d365081299affc7894bc88faa)
by [Unito](https://www.unito.io)
2025-09-23 14:50:44 -05:00
Johnpaul
e43a2fcc3e fix: add flex-wrap to bottom-right slot for stacking square chips 2025-09-23 20:28:07 +01:00
snomiao
c004a2b8bd chore(tsconfig): ensure complete TypeScript coverage for all project files (#5655)
## Summary
- Added missing directories and files to tsconfig.json to ensure
complete TypeScript type checking coverage
- Expanded config file patterns to include all .mts configuration files
- Verified all 908 TypeScript files in the project are now properly
covered

## Changes
- Added `scripts/**/*.ts` to cover all TypeScript files in scripts
directory (i18n collection, CI/CD scripts)
- Added `build/**/*.ts` to cover customIconCollection.ts and future
build scripts
- Changed `vite.config.mts` to `*.config.mts` to include all vite config
files (vite.electron.config.mts, vite.types.config.mts)

## Test plan
- [x] Run `pnpm typecheck` to verify no TypeScript errors
- [x] Verified all TypeScript files are covered by tsconfig patterns
- [x] browser_tests/ directory confirmed to have its own extending
tsconfig

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5655-chore-tsconfig-ensure-complete-TypeScript-coverage-for-all-project-files-2736d73d36508103acbadc53ca2b2913)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-23 09:31:30 -07:00
snomiao
d0aee031e9 [feat] Merge ComfyUI_devtools into ComfyUI_frontend (#5166)
## Summary
Merges ComfyUI devtools components into the ComfyUI frontend monorepo to
consolidate development tools.

## Changes
- Added devtools components from ComfyUI repository
- Integrated development nodes and utilities
- Consolidated fake model assets for testing

## Related Issues
Fixes #4683

## Testing
- Devtools components are now available within the frontend monorepo
- Development workflow remains consistent

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-22 23:15:50 -07:00
Johnpaul
2cf342197a fix: improve template loading feedback with better placement for title display 2025-09-23 06:37:18 +01:00
Johnpaul
920f6cfa91 fix: update template filtering and workflow templates to include 'Getting Started' category 2025-09-23 06:35:41 +01:00
Johnpaul
b6b4a562b9 fix: refactor containerClasses and containerStyle to use cn() and prevent undefined behaviour 2025-09-23 06:30:07 +01:00
Johnpaul
27e4ee5bd5 fix: update MultiSelect component to use VueUse's useFuse for improved search performance and reactivity 2025-09-23 05:44:12 +01:00
Johnpaul
91eb417eba fix: enhance loading states with skeletons and async state 2025-09-23 04:19:26 +01:00
Simula_r
cec1de0147 feat: vue nodes LOD system (#5631)
## Summary

Replaced reactive (Vue-based) widget LOD with CSS visibility control.
Performance doesn't dramatically improve, but we avoid the mount/unmount
overhead during zoom/pan operations. This PR implements the visual
component of LOD—complex widgets that need lifecycle management will be
addressed separately.

### Problem & Solution
Problem: we want LOD to improve rendering performance and visual
feedback but discovered using reactivity in the current setup for it
meant mounting/unmounting caused worse lag than the performance it aimed
to fix. Switching to render all the details all the time but using css
visibility proved to be the best solution. However, it doesn't improve
rendering performance by much because the GPU texture size is the
bottleneck (from TransformPane.vue CSS transforms) and not
rasterization.

Solution: Keep all nodes/widgets mounted, use CSS visibility: hidden for
LOD. Trade memory for performance stability during zoom/pan/drag
operations.

### Technical Decision
We chose Performance > Memory:

- CSS transforms create a single GPU texture whose size depends on node
count, not widget complexity
- Mounting/unmounting hundreds of widgets during zoom = noticeable lag
from Vue VDOM diffing (since all components are mounted all the time
because of viewport culling challenge/trade off see
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5510.)
- CSS visibility changes = no reactivity overhead, smooth interactions
- Result: Similar performance, but without interaction stutters

This is the visual layer only. If we want a hook into the LOD state per
node / widget that would be the next follow up system to implement.

### Next Steps (maybe)
- Chunked (split up single Transform Pane transform layer) when
rendering 1000+ nodes (maybe)
- ~~Selective unmounting API for widgets that register as "expensive"~~
- ~~Client bound hydration system~~

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

<img width="1355" height="960" alt="image"
src="https://github.com/user-attachments/assets/41474d1b-9dbe-4240-a8cf-f4c9ff51d8e0"
/>
<img width="1354" height="963" alt="image"
src="https://github.com/user-attachments/assets/9f55edaa-5858-41b9-b6a8-c2d37e1649bd"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5631-feat-vue-nodes-LOD-system-2726d73d365081c6a6c4e14aa634f19c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-22 20:05:13 -07:00
Johnpaul
e06f967803 fix: implement debounced search query in useTemplateFiltering for improved performance 2025-09-23 03:57:46 +01:00
Johnpaul
d1e8ec3373 fix: update placeholder text in SearchBox to use translation function 2025-09-23 03:57:18 +01:00
Christian Byrne
b4976c1ddc Revert: Move VueFire persistence configuration to initialization (#5614) (#5729)
## Summary
This reverts PR #5614 which moved VueFire persistence configuration to
initialization.

## Reason for Revert

It breaks Google SSO login with error:

```
useErrorHandling.ts:12 FirebaseError: Firebase: Error (auth/argument-error).
    at createErrorInternal (index-c92d61ad.js:506:41)
    at _assert (index-c92d61ad.js:512:15)
    at _withDefaultResolver (index-c92d61ad.js:9237:5)
    at signInWithPopup (index-c92d61ad.js:9457:30)
    at executeAuthAction.createCustomer (firebaseAuthStore.ts:263:25)
    at executeAuthAction (firebaseAuthStore.ts:223:28)
    at Proxy.loginWithGoogle (firebaseAuthStore.ts:262:5)
    at Proxy.wrappedAction (pinia.mjs:1405:26)
    at useFirebaseAuthActions.ts:104:28
    at Object.signInWithGoogle (useErrorHandling.ts:39:22)
```

## Changes
- Reverts commit ea4e57b60 "Move VueFire persistence configuration to
initialization (#5614)"
- Restores previous Firebase auth persistence behavior

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5729-Revert-Move-VueFire-persistence-configuration-to-initialization-5614-2776d73d3650814c9b80d9c67c852874)
by [Unito](https://www.unito.io)
2025-09-22 19:04:08 -07:00
Alexander Brown
1611c7a224 Refactor: Further state management cleanup (#5727)
## Summary

Going through the GraphNodeManager and VueNodeLifecycle one property at
a time and removing the pieces that are not currently wired up or used
by the rest of the application

Fixes paste location by updating the layoutStore in LGraphCanvas (which
already mutates layoutStore elsewhere)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5727-WIP-Refactor-Further-state-management-cleanup-2766d73d36508173b379c6009c194a5a)
by [Unito](https://www.unito.io)
2025-09-22 18:47:26 -07:00
Comfy Org PR Bot
d01081dab4 1.28.1 (#5728)
Patch version increment to 1.28.1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5728-1-28-1-2776d73d365081b287e4fdc5cbcea07b)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2025-09-22 20:36:12 -05:00
Johnpaul
4a784dfbcb fix: optimize template hover state with v-memo and release DOM refs on unmount 2025-09-23 01:10:11 +01:00
Johnpaul
430549767f refactor: simplify hover state management in WorkflowTemplateSelectorDialog 2025-09-23 00:41:50 +01:00
Johnpaul
c7b4ec815c fix: add overflow-y-scroll to LeftSidePanel for improved scrolling behavior 2025-09-22 22:56:51 +01:00
Johnpaul
7291255fa4 refactor: remove unused SMALL_MODEL_SIZE_LIMIT constant from templateWorkflows 2025-09-22 22:50:18 +01:00
Johnpaul
e72efade04 refactor: let categories on leftsidepanel be one to one with JSON 2025-09-22 22:48:04 +01:00
Johnpaul
699d4b4320 fix: add backgroundRepeat style to AudioThumbnail component 2025-09-22 22:47:26 +01:00
Johnpaul
6f135197a0 refactor: update template parameter types in WorkflowTemplateSelectorDialog 2025-09-22 22:46:53 +01:00
Johnpaul
b9cc103968 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-22 20:17:28 +01:00
Johnpaul
76308e7bf0 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-19 05:03:16 +01:00
Johnpaul
b48f91f925 feat: Add extension-specific filtering to template filters 2025-09-17 04:32:15 +01:00
Johnpaul
632f53ab45 feat: Enhance navigation with collapsible groups and improved NavTitle component 2025-09-17 04:24:46 +01:00
Johnpaul
d48ed607c7 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:27:15 +01:00
Johnpaul
9fae98e431 refactor: Update import paths for template workflows and workflow templates store 2025-09-17 03:16:49 +01:00
Johnpaul
a3ca397763 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:07:14 +01:00
Johnpaul
ccf2c5721d Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-17 03:07:00 +01:00
Christian Byrne
c9849935ac [test] Add component test for Vue button widget (#5468)
* add component test for button widget

* [move] relocate WidgetButton test to proper directory

Move test from src/ to tests-ui/ directory structure and update import path
to work from new location - addresses review comment about test organization

* [refactor] make widget name optional in mock factory

Add optional name parameter to createMockWidget function for more flexible
test setup - addresses @DrJKL's suggestion about optional parameters

* [refactor] use it.for for parameterized button tests

Replace forEach loops with it.for syntax for testing button severities
and variants - addresses @DrJKL's suggestion for better test structure

* [auto-fix] Apply ESLint and Prettier fixes

* Revert "[move] relocate WidgetButton test to proper directory"

This reverts commit e9f4d57334.

* [test] increase rapid clicks test from 5 to 16

Better test coverage for concurrent click handling

* name click number a shared variable

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-15 19:25:02 +01:00
Comfy Org PR Bot
5c64fcd604 [chore] Update electron-types to 0.4.72 (#5526)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-09-15 19:25:02 +01:00
filtered
cca69a8799 Fix update Action version extraction (#5527)
Was pulling frontend version instead.
2025-09-15 19:25:02 +01:00
snomiao
8cee668486 feat: add test count display to Playwright PR comments (#5458)
* feat: add test count display to Playwright PR comments

- Add extract-playwright-counts.mjs script to parse test results from Playwright reports
- Update pr-playwright-deploy-and-comment.sh to extract and display test counts
- Show overall summary with passed/failed/flaky/skipped counts
- Display per-browser test counts inline with report links
- Use dynamic status icons based on test results (//⚠️)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: include skipped test count in per-browser display

- Add skipped test extraction for individual browser reports
- Update per-browser display format to show all four counts:
  ( passed /  failed / ⚠️ flaky / ⏭️ skipped)
- Provides complete test result visibility at a glance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve test count extraction reliability in CI

- Use absolute paths for script and report directories
- Add debug logging to help diagnose extraction issues
- Move counts display after View Report link as requested
- Format: [View Report](url) •  passed /  failed / ⚠️ flaky / ⏭️ skipped

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: generate JSON reports alongside HTML for test count extraction

- Add JSON reporter to Playwright test runs
- Generate report.json alongside HTML reports
- Store JSON report in playwright-report directory
- This enables accurate test count extraction from CI artifacts

The HTML reports alone don't contain easily extractable test statistics
as they use a React app with dynamically loaded data. JSON reports
provide direct access to test counts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: correct JSON reporter syntax for Playwright tests

- Use proper syntax for JSON reporter with outputFile option
- Run separate commands for HTML and JSON report merging
- Specify output path directly in reporter configuration
- Ensures report.json is created in playwright-report directory

This fixes the "No such file or directory" error when trying to move
report.json file, as it wasn't being created in the first place.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "fix: correct JSON reporter syntax for Playwright tests"

This reverts commit 605d7cc1e2.

* fix: use correct Playwright reporter syntax with comma-separated list

- Use --reporter=html,json syntax (comma-separated, not space)
- Move test-results.json to playwright-report/report.json after generation
- Remove incorrect PLAYWRIGHT_JSON_OUTPUT_NAME env variable
- Add || true to prevent failure if JSON file doesn't exist

The JSON reporter outputs to test-results.json by default when using
the comma-separated reporter list syntax.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve test count extraction reliability in CI

- Use separate --reporter flags for list, html, and json
- Set PLAYWRIGHT_JSON_OUTPUT_NAME env var to specify JSON output path
- Run HTML and JSON report generation separately for merged reports
- Ensures report.json is created in playwright-report directory

The combined reporter syntax wasn't creating the JSON file properly.
Using separate reporter flags with env var ensures JSON is generated.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update scripts/cicd/pr-playwright-deploy-and-comment.sh

Co-authored-by: Alexander Brown <drjkl@comfy.org>

* refactor: convert extraction script to TypeScript and use tsx

- Convert extract-playwright-counts.mjs to TypeScript (.ts)
- Add proper TypeScript types for better type safety
- Use tsx for execution instead of node
- Auto-install tsx in CI if not available
- Better alignment with the TypeScript codebase

This provides better type safety and consistency with the rest of
the codebase while maintaining the same functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(pr-playwright-deploy-and-comment.sh): move tsx installation check to the beginning of the script for better organization and efficiency

* [auto-fix] Apply ESLint and Prettier fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-09-15 19:25:02 +01:00
Christian Byrne
c673ce9d27 [test] Add Vue Node header component test (#5457)
* add node header component test

* [refactor] use separate const declarations instead of mutable variable in test - addresses @DrJKL's code style suggestion

Replace mutable `let icon` with descriptive `const expandedIcon` and `const collapsedIcon`
variables for better code clarity and immutability in the chevron icon test.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [config] remove unnecessary vitest exclude patterns - addresses @DrJKL's configuration review

Remove redundant exclude patterns from vitest config as they are already covered by
vitest's default exclusions. Simplifies configuration while maintaining functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [config] remove unnecessary vitest exclude patterns - addresses @DrJKL's configuration review

Remove redundant exclude patterns from vitest config as they are already covered by
vitest's default exclusions. Simplifies configuration while maintaining functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 19:25:02 +01:00
Alexander Brown
3d5bbbeb31 a11y: Bigger click/touch target for Slider track (#5524)
* a11y: Bigger click/touch target for Slider track

* a11y: z-index fix and bigger thumb too.
2025-09-15 19:25:02 +01:00
Christian Byrne
066d292eea ADR: PrimeVue Fork Decision (#5230)
* ADR: Add PrimeVue fork decision record

Adds ADR-0003 documenting the decision to fork PrimeVue as a monorepo workspace package. Key rationale includes transform coordinate system conflicts and virtual canvas scroll interference that require component-level modifications.

* ADR: Reject PrimeVue fork decision

- Change status from Proposed to Rejected
- Document rationale: implementation complexity with dual monorepos,
  maintenance burden, alternative solutions available
- Add specific code citations and repository links
- Include alternative approach using shadcn/ui for selective replacement
2025-09-15 19:25:02 +01:00
Alexander Brown
a28a52c9c0 Component: Vue Widget Slider (new) (#5516)
* feat: Initial shadcn configuration

* component: Add Slider component from shadcn-vue

* deps: Add tw-animate-css

* component: Align slider with Figma styles

* component: Set the step value for the slider, update styles

* fix: update component tests to work with Array of values

* vite: Don't reload dev server for test changes

* component: Swap text for a number input kept in sync with the slider

* cleanup: Don't need the override if the input isn't type="number"

* test: add step size tests

* cleanup: Don't need cn for these

* css: Update token names to match new Figma Variables

* lint: Fix camelCase vs train-case in passthrough

* feat: If the value is deleted, revert to the slider state cc: @PabloWiedemann

* feat: Improve cursor styles, grabbable thumb, clickable track

* lint: temporarily disable some warnings

* feat: Grabbing while sliding (most of the time)
2025-09-15 19:25:02 +01:00
Alexander Brown
64058e8e55 Feat: Change the Run button / ActionBar to dock by default (#5519)
* Feat: Change the Run button / ActionBar to dock by default

@PabloWiedemann

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-15 19:25:02 +01:00
Benjamin Lu
8cb10b0487 fix(links): remove drag-end offset in straight/linear modes by honoring CENTER/NONE as no-offset (#5520)
- Add 'none' direction to path renderer
- Map CENTER/NONE to 'none' in adapter
- Keep start-end offset intentional; end follows cursor exactly
- Preserve spline behavior and arrow rendering

Verified with typecheck; no visual changes outside dragging behavior.
2025-09-15 19:25:02 +01:00
Johnpaul
578bb54437 fix: enhance CardContainer and MultiSelect components with improved props handling and filtering logic 2025-09-13 02:35:36 +01:00
Johnpaul
6844b77899 fix: update fallback behavior for loading core workflow templates to default to English 2025-09-13 02:35:36 +01:00
Johnpaul
f0c8a6d1ac fix: add data-testid attributes for template workflows in WorkflowTemplateSelectorDialog 2025-09-13 02:35:36 +01:00
Johnpaul Chiwetelu
b1b1312807 Merge branch 'main' into feat/new-workflow-templates 2025-09-12 20:57:51 +01:00
Johnpaul
1bd6fddf2d fix: update category icon mappings to use Lucide icon classes 2025-09-12 20:55:52 +01:00
Johnpaul
482dfcc0c7 fix: reset base components to use default props 2025-09-12 20:55:03 +01:00
Johnpaul
1864b1c9bb fix: Replace BaseWidgetLayout with BaseModalLayout and ensure hoveredTemplates fallback 2025-09-12 20:52:22 +01:00
Johnpaul
406a53a14e Merge branch 'main' into feat/new-workflow-templates 2025-09-12 20:25:18 +01:00
Johnpaul
619728ca2b Merge branch 'main' into feat/new-workflow-templates 2025-09-09 02:25:06 +01:00
Johnpaul
2a50793f16 refactor: update BaseThumbnail tests and improve template description handling 2025-09-09 02:24:16 +01:00
Johnpaul
54c7851dbe refactor: delete unused components 2025-09-09 02:23:50 +01:00
Johnpaul
a96a8f5a4e refactor: rename NewWorkflowTemplateSelectorDialog 2025-09-09 01:31:56 +01:00
Johnpaul
06f6b67ecb refactor: remove old WorkflowTemplateSelector component 2025-09-09 01:30:38 +01:00
Johnpaul
cb3a29623b refactor: remove unused getCategoryIconByTitle function and clean up categoryIcons utility 2025-09-09 01:24:19 +01:00
Johnpaul
deb3d942b8 refactor: replace dialogService method call with useWorkflowTemplateSelectorDialog hook 2025-09-09 01:17:10 +01:00
Johnpaul
180a8ea16c refactor: update title styling and enhance hover transition effects 2025-09-09 01:02:25 +01:00
Johnpaul
73af4d5d7a refactor: enhance MultiSelect component with search functionality and dynamic options filtering 2025-09-09 00:51:53 +01:00
Johnpaul
88aa77a4e3 refactor: add page title display and improve template card structure 2025-09-08 20:34:55 +01:00
Johnpaul
a883288a70 refactor: adjust sort options container position and update SingleSelect width 2025-09-08 19:01:08 +01:00
Johnpaul
521842f3b7 refactor: update default sort option to 'newest' 2025-09-08 18:54:21 +01:00
Johnpaul
e465d1f50d refactor: update NavItem font size from text-xs to text-sm 2025-09-08 17:49:17 +01:00
Johnpaul
967e506821 refactor: update button label and visibility, adjust CardBottom properties, and enhance AudioThumbnail styling 2025-09-08 17:47:51 +01:00
Johnpaul
c0eacb7e97 refactor: update SingleSelect label class and adjust NavItem icon size 2025-09-08 17:47:30 +01:00
Johnpaul
0e3da72267 refactor: adjust MultiSelect overlay height 2025-09-08 17:46:19 +01:00
Johnpaul
4d70fed532 Add props to CatdBottom and CardContainer 2025-09-08 17:45:03 +01:00
Johnpaul
f896686e9e refactor: simplify thumbnail source retrieval and enhance onLoadWorkflow function 2025-09-08 02:26:10 +01:00
Johnpaul
387c088285 refactor: move tutorial button to card bottom 2025-09-07 23:38:12 +01:00
Johnpaul
deb3f27c74 wip: reposition workflow title text 2025-09-06 00:02:31 +01:00
Johnpaul
956692ba16 WIP use new workflowtemplate selector to match design 2025-09-05 22:53:33 +01:00
Johnpaul
142179d63f feat: enable search box in MultiSelect and replace tag spans with SquareChip in TemplateWorkflowCard 2025-09-05 22:08:13 +01:00
Johnpaul
c6933a4fdf style: enhance MultiSelect component's overlay and list for better scrolling experience 2025-09-05 22:06:58 +01:00
Johnpaul
eb72d04f99 Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-09-05 20:22:26 +01:00
Johnpaul
24af9763d2 fix: remove LoRA training filter and counts from workflow templates 2025-08-30 01:41:36 +01:00
Johnpaul
01827df5f0 Implement falback default template Image 2025-08-28 21:30:11 +01:00
Johnpaul
1705760d29 fix: remove performance filters and counts from workflow templates 2025-08-28 20:53:24 +01:00
Johnpaul
fde58abda3 feat: add tutorial button and corresponding tooltip in TemplateWorkflowCard 2025-08-28 20:46:36 +01:00
Johnpaul
a3f026563a feat: add LLM category support and corresponding icon in workflow templates 2025-08-28 19:15:52 +01:00
Johnpaul
4471c069fe fix: update icon in WorkflowTemplateSelector and improve tag display in TemplateWorkflowCard 2025-08-28 19:07:35 +01:00
Johnpaul
b243b46cb4 feat: implement model size sorting and enhance performance filtering logic 2025-08-28 02:10:59 +01:00
Johnpaul
fa317d7172 fix: adjust dialog content styling for improved layout management 2025-08-28 01:36:58 +01:00
Johnpaul
5d88200aae fix layout overflow issues on bigger screens 2025-08-28 01:00:00 +01:00
Johnpaul
f6dc62ab8f Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates 2025-08-27 22:53:16 +01:00
Johnpaul
d273c1f348 Merge branch 'main' into feat/new-workflow-templates 2025-08-27 22:30:57 +01:00
Johnpaul
c6d6de74d6 [skip ci] 2025-08-21 02:43:10 +01:00
Johnpaul
8fb225f1f6 feat: add new sorting options for template filtering by VRAM utilization and model size 2025-08-20 22:31:17 +01:00
Johnpaul
9ec2f69282 feat: add category icon mapping functions for IDs and titles 2025-08-20 21:11:19 +01:00
Johnpaul
18482ac385 feat: enhance navigation and template filtering with icon support and new category mappings 2025-08-20 21:10:39 +01:00
Johnpaul
989e4e50d3 feat: remove TemplateSearchBar component and enhance template filtering options with use cases and licenses 2025-08-20 19:39:37 +01:00
Johnpaul
ce30ea3417 feat: enhance MultiSelect and SearchBox components with improved filtering and search functionality 2025-08-20 19:38:53 +01:00
Johnpaul
8e36c347c2 feat: use old template workflow view in workflow selector 2025-08-20 18:04:40 +01:00
Johnpaul
248a4c8072 Merge branch 'main' into feat/new-workflow-templates 2025-08-20 17:39:06 +01:00
Johnpaul
d36c868a7e feat: Implement workflow template selector dialog and enhance template filtering options 2025-08-20 17:18:11 +01:00
Johnpaul
ac8b108f39 feat: Enhance template filtering with Fuse.js and add model selection 2025-08-20 17:17:13 +01:00
128 changed files with 4807 additions and 3857 deletions

View File

@@ -32,11 +32,10 @@ jobs:
with:
repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI_devtools
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout custom node repository
uses: actions/checkout@v4
with:

View File

@@ -27,12 +27,10 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Copy ComfyUI_devtools from frontend repo
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4

View File

@@ -16,9 +16,14 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
For local development, copy the devtools files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -80,6 +80,12 @@ test.describe('Templates', () => {
// Load a template
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
@@ -102,48 +108,72 @@ test.describe('Templates', () => {
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise
// Verify French index was requested
expect(request.url()).toContain('templates/index.fr.json')
await expect(comfyPage.templates.content).toBeVisible()
})
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
// Set locale to a language that doesn't have a template file
await comfyPage.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Expect the title to be used as fallback for template cards
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
comfyPage.templates.content.getByRole('heading', {
name: 'Image Generation'
})
).toBeVisible()
// 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 ({
@@ -153,25 +183,43 @@ test.describe('Templates', () => {
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
})
const firstCard = comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
// Get the template grid
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
// Check grid layout at desktop size (default)
const desktopGridClass = await templateGrid.getAttribute('class')
expect(desktopGridClass).toContain('grid')
expect(desktopGridClass).toContain(
'grid-cols-[repeat(auto-fill,minmax(16rem,1fr))]'
)
// Count visible cards at desktop size
const desktopCardCount = await comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.count()
expect(desktopCardCount).toBeGreaterThan(0)
// 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')
// Grid should still be responsive at mobile size
const mobileGridClass = await templateGrid.getAttribute('class')
expect(mobileGridClass).toContain('grid')
// 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')
// Grid should still be responsive at tablet size
const tabletGridClass = await templateGrid.getAttribute('class')
expect(tabletGridClass).toContain('grid')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
@@ -179,10 +227,13 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
// Get a template card using data-testid
const firstCard = comfyPage.page
.locator('[data-testid^="template-workflow-"]')
.first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Check initial state - card should have transition classes
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
@@ -257,21 +308,42 @@ test.describe('Templates', () => {
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify cards are visible with varying content lengths
// Wait for cards to load
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')
comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
).toBeVisible({ timeout: 5000 })
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
// Verify all three cards with different descriptions are visible
const shortDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-short-description"]'
)
const mediumDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-medium-description"]'
)
const longDescCard = comfyPage.page.locator(
'[data-testid="template-workflow-long-description"]'
)
await expect(shortDescCard).toBeVisible()
await expect(mediumDescCard).toBeVisible()
await expect(longDescCard).toBeVisible()
// Verify descriptions are visible and have line-clamp class
// The description is in a p tag with text-muted class
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
await expect(shortDesc).toContainText('short description')
await expect(mediumDesc).toContainText('medium length description')
await expect(longDesc).toContainText('much longer description')
// Verify grid layout maintains consistency
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const buttonsInNodes = vueNodesContainer.getByRole('button')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(buttonsInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -3,7 +3,6 @@
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true

17
build/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Build scripts configuration */
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -33,7 +33,13 @@ export default defineConfig([
},
parserOptions: {
parser: tseslint.parser,
projectService: true,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts'
]
},
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.0",
"version": "1.28.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -123,6 +123,7 @@
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@vueuse/core": "^11.0.0",
"@vueuse/integrations": "^13.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",

87
pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
'@vueuse/core':
specifier: ^11.0.0
version: 11.0.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/integrations':
specifier: ^13.9.0
version: 13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -2895,18 +2898,73 @@ packages:
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/integrations@13.9.0':
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
peerDependencies:
async-validator: ^4
axios: ^1
change-case: ^5
drauu: ^0.4
focus-trap: ^7
fuse.js: ^7
idb-keyval: ^6
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
universal-cookie: ^7 || ^8
vue: ^3.5.0
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
'@vueuse/metadata@11.0.0':
resolution: {integrity: sha512-0TKsAVT0iUOAPWyc9N79xWYfovJVPATiOPVKByG6jmAYdDiwvMVm9xXJ5hp4I8nZDxpCcYlLq/Rg9w1Z/jrGcg==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/shared@11.0.0':
resolution: {integrity: sha512-i4ZmOrIEjSsL94uAEt3hz88UCz93fMyP/fba9S+vypX90fKg3uYX9cThqvWc9aXxuTzR0UGhOKOTQd//Goh1nQ==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
@@ -6323,6 +6381,9 @@ packages:
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8879,7 +8940,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
vue-component-type-helpers: 3.0.8
'@swc/helpers@0.5.17':
dependencies:
@@ -9662,10 +9723,28 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/core@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
'@vueuse/integrations@13.9.0(axios@1.11.0)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.13(typescript@5.9.2))
'@vueuse/shared': 13.9.0(vue@3.5.13(typescript@5.9.2))
vue: 3.5.13(typescript@5.9.2)
optionalDependencies:
axios: 1.11.0
fuse.js: 7.0.0
'@vueuse/metadata@11.0.0': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@13.9.0': {}
'@vueuse/shared@11.0.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.2))
@@ -9679,6 +9758,10 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/shared@13.9.0(vue@3.5.13(typescript@5.9.2))':
dependencies:
vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.51': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
@@ -13545,6 +13628,8 @@ snapshots:
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -9,9 +9,18 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
interface WidgetInfo {
name?: string
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
comfyPage.page.route('**/view**', async (route) => {
await comfyPage.page.route('**/view**', async (route) => {
await route.fulfill({
body: JSON.stringify({})
})
@@ -20,6 +29,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api as ComfyApi
return await api.getNodeDefs()
})
@@ -52,7 +62,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
async function extractWidgetLabels() {
const nodeLabels = {}
const nodeLabels: WidgetLabels = {}
for (const nodeDef of nodeDefs) {
const inputNames = Object.values(nodeDef.inputs).map(
@@ -65,12 +75,15 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const widgetsMappings = await comfyPage.page.evaluate(
(args) => {
const [nodeName, displayName, inputNames] = args
// @ts-expect-error - LiteGraph is dynamically added to window
const node = window['LiteGraph'].createNode(nodeName, displayName)
if (!node.widgets?.length) return {}
return Object.fromEntries(
node.widgets
.filter((w) => w?.name && !inputNames.includes(w.name))
.map((w) => [w.name, w.label])
.filter(
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
)
.map((w: WidgetInfo) => [w.name, w.label])
)
},
[nodeDef.name, nodeDef.display_name, inputNames]

View File

@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
const relativePath = file.replace(srcLocaleDir, '')
const targetPath = join(tempBaseDir, relativePath)
ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file))
writeFileSync(targetPath, readFileSync(file, 'utf8'))
}
console.log('Captured current locale files to temp/base/')
}

14
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Script files configuration */
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -929,48 +929,6 @@ audio.comfy-audio.empty-audio-widget {
}
/* End of [Desktop] Electron window specific styles */
/* Vue Node LOD (Level of Detail) System */
/* These classes control rendering detail based on zoom level */
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
.lg-node--lod-minimal {
min-height: 32px;
transition: min-height 0.2s ease;
/* Performance optimizations */
text-shadow: none;
backdrop-filter: none;
}
.lg-node--lod-minimal .lg-node-body {
display: none !important;
}
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
.lg-node--lod-reduced {
transition: opacity 0.1s ease;
/* Performance optimizations */
text-shadow: none;
}
.lg-node--lod-reduced .lg-widget-label,
.lg-node--lod-reduced .lg-slot-label {
display: none;
}
.lg-node--lod-reduced .lg-slot {
opacity: 0.6;
font-size: 0.75rem;
}
.lg-node--lod-reduced .lg-widget {
margin: 2px 0;
font-size: 0.875rem;
}
/* Full LOD (zoom > 0.8) - Complete detail rendering */
.lg-node--lod-full {
/* Uses default styling - no overrides needed */
}
.lg-node {
/* Disable text selection on all nodes */
@@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform;
}
/* Global performance optimizations for LOD */
.lg-node--lod-minimal,
.lg-node--lod-reduced {
/* Remove ALL expensive paint effects */
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
text-shadow: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
clip-path: none !important;
/* START LOD specific styles */
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.isLOD .lg-node {
box-shadow: none;
filter: none;
backdrop-filter: none;
text-shadow: none;
-webkit-mask-image: none;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
}
/* Reduce paint complexity for minimal LOD */
.lg-node--lod-minimal {
/* Skip complex borders */
border-radius: 0 !important;
/* Use solid colors only */
background-image: none !important;
.isLOD .lg-node > * {
pointer-events: none;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_1416_62)">
<path d="M7.99998 10.6667V8.00004M7.99998 5.33337H8.00665M14.6666 8.00004C14.6666 11.6819 11.6819 14.6667 7.99998 14.6667C4.31808 14.6667 1.33331 11.6819 1.33331 8.00004C1.33331 4.31814 4.31808 1.33337 7.99998 1.33337C11.6819 1.33337 14.6666 4.31814 14.6666 8.00004Z" stroke="#171718" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1416_62">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -1,5 +1,8 @@
<template>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
<div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
@@ -98,12 +101,13 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
overflow-x: auto;
@apply overflow-hidden;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
@apply bg-neutral-900 overflow-hidden;
}
</style>

View File

@@ -1,7 +1,19 @@
<template>
<div class="flex-1 w-full h-full">
<div :class="containerClasses">
<slot></slot>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { fullHeight = true } = defineProps<{
fullHeight?: boolean
}>()
const containerClasses = computed(() =>
cn('flex-1 w-full', fullHeight && 'h-full')
)
</script>

View File

@@ -8,15 +8,21 @@
<script setup lang="ts">
import { computed } from 'vue'
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'portrait' | 'tallPortrait'
const { ratio = 'square', type } = defineProps<{
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
type?: string
}>()
const containerClasses = computed(() => {
const baseClasses =
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
if (type === 'workflow-template-card') {
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
}
const ratioClasses = {
smallSquare: 'aspect-240/311',
square: 'aspect-256/308',
portrait: 'aspect-256/325',
tallPortrait: 'aspect-256/353'

View File

@@ -2,26 +2,40 @@
<div :class="topStyle">
<slot class="absolute top-0 left-0 w-full h-full"></slot>
<div class="absolute top-2 left-2 flex gap-2">
<div
v-if="slots['top-left']"
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
>
<slot name="top-left"></slot>
</div>
<div class="absolute top-2 right-2 flex gap-2">
<div
v-if="slots['top-right']"
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
>
<slot name="top-right"></slot>
</div>
<div class="absolute bottom-2 left-2 flex gap-2">
<div
v-if="slots['bottom-left']"
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
>
<slot name="bottom-left"></slot>
</div>
<div class="absolute bottom-2 right-2 flex gap-2">
<div
v-if="slots['bottom-right']"
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
>
<slot name="bottom-right"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
const slots = useSlots()
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'landscape'

View File

@@ -24,7 +24,13 @@
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
<img
src="/assets/images/default-template.png"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<template>
<div :class="wrapperClass">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img
src="/assets/images/comfy-brand-mark.svg"
:alt="t('g.logoAlt')"
class="w-60"
/>
</div>
<!-- Bottom container: Progress and text -->
<div class="flex flex-col items-center justify-center gap-4">
<ProgressBar
v-if="!hideProgress"
:mode="progressMode"
:value="progressPercentage ?? 0"
:show-value="false"
class="w-90 h-2 mt-8"
:pt="{ value: { class: 'bg-brand-yellow' } }"
/>
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Props for the StartupDisplay component */
interface StartupDisplayProps {
/** Progress: 0-100 for determinate, undefined for indeterminate */
progressPercentage?: number
/** Main title text */
title?: string
/** Status text shown below the title */
statusText?: string
/** Hide the progress bar */
hideProgress?: boolean
/** Use full screen wrapper (default: true) */
fullScreen?: boolean
}
const {
progressPercentage,
title,
statusText,
hideProgress = false,
fullScreen = true
} = defineProps<StartupDisplayProps>()
const progressMode = computed(() =>
progressPercentage === undefined ? 'indeterminate' : 'determinate'
)
const wrapperClass = computed(() =>
fullScreen
? 'flex items-center justify-center min-h-screen'
: 'flex items-center justify-center'
)
</script>

View File

@@ -0,0 +1,750 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
</template>
<template #header-right-area>
<div class="flex gap-2">
<IconTextButton
v-if="filteredCount !== totalCount"
type="secondary"
:label="$t('templateWorkflows.resetFilters', 'Clear Filters')"
@click="resetFilters"
>
<template #icon>
<i-lucide:filter-x />
</template>
</IconTextButton>
</div>
</template>
<template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
v-model:search-query="modelSearchText"
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:cpu />
</template>
</MultiSelect>
<!-- Use Case Filter -->
<MultiSelect
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:target />
</template>
</MultiSelect>
<!-- License Filter -->
<MultiSelect
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i-lucide:file-text />
</template>
</MultiSelect>
<!-- Sort Options -->
<div class="absolute right-5">
<SingleSelect
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
class="min-w-[270px]"
>
<template #icon>
<i-lucide:arrow-up-down />
</template>
</SingleSelect>
</div>
</div>
<div
v-if="!isLoading"
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
>
<span>
{{ pageTitle }}
</span>
</div>
</template>
<template #content>
<!-- No Results State (only show when loaded and no results) -->
<div
v-if="!isLoading && filteredTemplates.length === 0"
class="flex flex-col items-center justify-center h-64 text-neutral-500"
>
<i-lucide:search class="w-12 h-12 mb-4 opacity-50" />
<p class="text-lg mb-2">
{{ $t('templateWorkflows.noResults', 'No templates found') }}
</p>
<p class="text-sm">
{{
$t(
'templateWorkflows.noResultsHint',
'Try adjusting your search or filters'
)
}}
</p>
</div>
<div v-else>
<!-- Title -->
<span
v-if="isLoading"
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></span>
<!-- Template Cards Grid -->
<div
:key="templateListKey"
:style="gridStyle"
data-testid="template-workflows-content"
>
<!-- Loading Skeletons (show while loading initial data) -->
<CardContainer
v-for="n in isLoading ? 12 : 0"
:key="`initial-skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="landscape">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
:key="template.name"
ref="cardRefs"
v-memo="[template.name, hoveredTemplate === template.name]"
ratio="smallSquare"
type="workflow-template-card"
:data-testid="`template-workflow-${template.name}`"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
>
<template #top>
<CardTop ratio="square">
<template #default>
<!-- Template Thumbnail -->
<div
class="w-full h-full relative rounded-lg overflow-hidden"
>
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
</template>
<template
v-else-if="template.thumbnailVariant === 'compareSlider'"
>
<CompareSliderThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template
v-else-if="template.thumbnailVariant === 'hoverDissolve'"
>
<HoverDissolveThumbnail
:base-image-src="getBaseThumbnailSrc(template)"
:overlay-image-src="getOverlayThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="getBaseThumbnailSrc(template)"
:alt="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
:is-hovered="hoveredTemplate === template.name"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover' ? 16 : 5
"
/>
</template>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 w-12 h-12 m-auto"
/>
</div>
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
v-for="tag in template.tags"
:key="tag"
:label="tag"
/>
</template>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="flex flex-col gap-2 pt-3">
<h3
class="line-clamp-1 text-sm m-0"
:title="
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
"
>
{{
getTemplateTitle(
template,
getEffectiveSourceModule(template)
)
}}
</h3>
<div class="flex justify-between gap-2">
<div class="flex-1">
<p
class="line-clamp-2 text-sm text-muted m-0"
:title="getTemplateDescription(template)"
>
{{ getTemplateDescription(template) }}
</p>
</div>
<div
v-if="template.tutorialUrl"
class="flex flex-col-reverse justify-center"
>
<IconButton
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
v-bind="$attrs"
type="primary"
size="sm"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />
</IconButton>
</div>
</div>
</div>
</CardBottom>
</template>
</CardContainer>
<!-- Loading More Skeletons -->
<CardContainer
v-for="n in isLoadingMore ? 6 : 0"
:key="`skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
></div>
</div>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<!-- Load More Trigger -->
<div
v-if="!isLoading && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center mt-4"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
</div>
</div>
<!-- Results Summary -->
<div
v-if="!isLoading"
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
>
{{
$t('templateWorkflows.resultsCount', {
count: filteredCount,
total: totalCount
})
}}
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
// Workflow templates store and composable
const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank')
}
}
// Get navigation items from the store, with skeleton items while loading
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
// Show skeleton navigation items while loading
if (isLoading.value) {
return [
{
id: 'skeleton-all',
label: 'All Templates',
icon: 'icon-[lucide--layout-grid]'
},
{
id: 'skeleton-basics',
label: 'Basics',
icon: 'icon-[lucide--graduation-cap]'
},
{
title: 'Generation Type',
items: [
{ id: 'skeleton-1', label: '...', icon: 'icon-[lucide--loader-2]' },
{ id: 'skeleton-2', label: '...', icon: 'icon-[lucide--loader-2]' }
]
},
{
title: 'Closed Source Models',
items: [
{ id: 'skeleton-3', label: '...', icon: 'icon-[lucide--loader-2]' }
]
}
]
}
return workflowTemplatesStore.navGroupedTemplates
})
const gridStyle = computed(() => createGridStyle())
// Get enhanced templates for better filtering
const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
return allTemplates.value
}
return workflowTemplatesStore.filterTemplatesByCategory(selectedNavItem.value)
})
// Template filtering
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
totalCount,
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
return selectedModels.value.map((model) => ({ name: model, value: model }))
},
set(value: { name: string; value: string }[]) {
selectedModels.value = value.map((item) => item.value)
}
})
const selectedUseCaseObjects = computed({
get() {
return selectedUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
},
set(value: { name: string; value: string }[]) {
selectedUseCases.value = value.map((item) => item.value)
}
})
const selectedLicenseObjects = computed({
get() {
return selectedLicenses.value.map((license) => ({
name: license,
value: license
}))
},
set(value: { name: string; value: string }[]) {
selectedLicenses.value = value.map((item) => item.value)
}
})
// Loading states
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Search text for model filter
const modelSearchText = ref<string>('')
// Filter options
const modelOptions = computed(() =>
availableModels.value.map((model) => ({
name: model,
value: model
}))
)
const useCaseOptions = computed(() =>
availableUseCases.value.map((useCase) => ({
name: useCase,
value: useCase
}))
)
const licenseOptions = computed(() =>
availableLicenses.value.map((license) => ({
name: license,
value: license
}))
)
// Filter labels
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
})
const licenseFilterLabel = computed(() => {
if (selectedLicenseObjects.value.length === 0) {
return t('templateWorkflows.licenseFilter', 'License')
} else if (selectedLicenseObjects.value.length === 1) {
return selectedLicenseObjects.value[0].name
} else {
return t('templateWorkflows.licensesSelected', {
count: selectedLicenseObjects.value.length
})
}
})
// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t(
'templateWorkflows.sort.modelSizeLowToHigh',
'Model Size (Low to High)'
),
value: 'model-size-low-to-high'
},
{
name: t('templateWorkflows.sort.alphabetical', 'Alphabetical (A-Z)'),
value: 'alphabetical'
}
])
// Lazy pagination setup
const loadTrigger = ref<HTMLElement | null>(null)
const shouldUsePagination = computed(() => !searchQuery.value.trim())
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset: resetPagination
} = useLazyPagination(filteredTemplates, { itemsPerPage: 24 }) // Load 24 items per page
// Display templates (all when searching, paginated when not)
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Set up intersection observer for lazy loading
useIntersectionObserver(loadTrigger, () => {
if (
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
})
// Reset pagination when filters change
watch(
[
searchQuery,
selectedNavItem,
sortBy,
selectedModels,
selectedUseCases,
selectedLicenses
],
() => {
resetPagination()
// Clear loading state and force re-render of template list
loadingTemplate.value = null
templateListKey.value++
}
)
// Methods
const onLoadWorkflow = async (template: any) => {
loadingTemplate.value = template.name
try {
await loadWorkflowTemplate(
template.name,
getEffectiveSourceModule(template)
)
onClose()
} finally {
loadingTemplate.value = null
}
}
const pageTitle = computed(() => {
const navItem = navItems.value.find((item) =>
'id' in item
? item.id === selectedNavItem.value
: item.items?.some((sub) => sub.id === selectedNavItem.value)
)
if (!navItem) {
return t('templateWorkflows.allTemplates', 'All Templates')
}
return 'id' in navItem
? navItem.label
: navItem.items?.find((i) => i.id === selectedNavItem.value)?.label ||
t('templateWorkflows.allTemplates', 'All Templates')
})
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run both operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
])
return true
},
false, // initial state
{
immediate: true // Start loading immediately
}
)
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -49,11 +49,11 @@
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
@@ -152,9 +152,7 @@ const repositionPopover = () => {
}
}
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
autoStart: false
})
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl()

View File

@@ -9,7 +9,7 @@
-->
<MultiSelect
v-model="selectedItems"
v-bind="$attrs"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
@@ -105,10 +105,11 @@
</template>
<script setup lang="ts">
import { type UseFuseOptions, useFuse } from '@vueuse/integrations/useFuse'
import Button from 'primevue/button'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/input/SearchBox.vue'
@@ -158,7 +159,7 @@ const {
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
@@ -167,6 +168,40 @@ const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
includeScore: false
},
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({

View File

@@ -1,9 +1,15 @@
<template>
<div :class="wrapperStyle">
<div :class="wrapperStyle" @click="focusInput">
<i-lucide:search :class="iconColorStyle" />
<InputText
ref="input"
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
:aria-label="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
:placeholder="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
type="text"
unstyled
:class="inputStyle"
@@ -13,8 +19,9 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const {
@@ -29,6 +36,13 @@ const {
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const input = ref<{ $el: HTMLElement } | null>()
const focusInput = () => {
if (input.value && input.value.$el) {
input.value.$el.focus()
}
}
const wrapperStyle = computed(() => {
const baseClasses = [
'relative flex w-full items-center gap-2',

View File

@@ -143,7 +143,7 @@ const pt = computed(() => ({
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
},
dropdown: {
class:

View File

@@ -10,14 +10,14 @@
</p>
</div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
<!-- Auto Update Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
<p class="text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }}
</p>
</div>
@@ -32,14 +32,10 @@
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
<p class="text-neutral-400">
{{ $t('install.settings.allowMetricsDescription') }}
</p>
<a
href="#"
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
@click.prevent="showMetricsInfo"
>
<a href="#" @click.prevent="showMetricsInfo">
{{ $t('install.settings.learnMoreAboutData') }}
</a>
</div>
@@ -51,7 +47,9 @@
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t('install.settings.dataCollectionDialog.title')"
class="select-none"
>
<div class="text-neutral-300">
<h4 class="font-medium mb-2">
@@ -110,11 +108,7 @@
</ul>
<div class="mt-4">
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
>
<a href="https://comfy.org/privacy" target="_blank">
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
</a>
</div>

View File

@@ -1,130 +1,66 @@
<template>
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-4 text-neutral-300">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.gpuSelection.selectGpu') }}
</h2>
<div
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
>
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuPicker.title') }}
</h2>
<p class="m-1 text-neutral-400">
{{ $t('install.gpuSelection.selectGpuDescription') }}:
</p>
<!-- GPU Selection buttons - takes up remaining space and centers content -->
<div class="flex-1 flex gap-8 justify-center items-center">
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
<!-- Manual Install -->
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
</div>
<!-- GPU Selection buttons -->
<div
class="flex gap-2 text-center transition-opacity"
:class="{ selected: selected }"
>
<!-- NVIDIA -->
<div
v-if="platform !== 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'nvidia' }"
role="button"
@click="pickGpu('nvidia')"
>
<img
class="m-12"
alt="NVIDIA logo"
width="196"
height="32"
src="/assets/images/nvidia-logo.svg"
/>
</div>
<!-- MPS -->
<div
v-if="platform === 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'mps' }"
role="button"
@click="pickGpu('mps')"
>
<img
class="rounded-lg hover-brighten"
alt="Apple Metal Performance Shaders Logo"
width="292"
ratio
src="/assets/images/apple-mps-logo.png"
/>
</div>
<!-- Manual configuration -->
<div
class="gpu-button"
:class="{ selected: selected === 'unsupported' }"
role="button"
@click="pickGpu('unsupported')"
>
<img
class="m-12"
alt="Manual configuration"
width="196"
src="/assets/images/manual-configuration.svg"
/>
</div>
</div>
<!-- Details on selected GPU -->
<p v-if="selected === 'nvidia'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
{{ $t('install.gpuSelection.nvidiaDescription') }}
</p>
<p v-if="selected === 'mps'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
{{ $t('install.gpuSelection.mpsDescription') }}
</p>
<div v-if="selected === 'unsupported'" class="text-neutral-300">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.customSkipsPython') }}
</p>
<ul>
<li>
<strong>
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
</strong>
</li>
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
</ul>
</div>
<div v-if="selected === 'cpu'">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.cpuModeDescription') }}
</p>
<p class="m-1">
{{ $t('install.gpuSelection.cpuModeDescription2') }}
</p>
<div class="pt-12 px-24 h-16">
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
<Tag
:value="$t('install.gpuPicker.recommended')"
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
/>
<i-lucide:badge-check class="text-neutral-300 text-lg" />
</div>
</div>
<div
class="transition-opacity flex gap-3 h-0"
:class="{
'opacity-40': selected && selected !== 'cpu'
}"
>
<ToggleSwitch
v-model="cpuMode"
input-id="cpu-mode"
class="-translate-y-40"
/>
<label for="cpu-mode" class="select-none">
{{ $t('install.gpuSelection.enableCpuMode') }}
</label>
<div class="text-neutral-300 px-24">
<p v-show="descriptionText" class="leading-relaxed">
{{ descriptionText }}
</p>
</div>
</div>
</template>
@@ -132,20 +68,12 @@
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HardwareOption from '@/components/install/HardwareOption.vue'
import { st } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()
const cpuMode = computed({
get: () => selected.value === 'cpu',
set: (value) => {
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType | null>('device', {
required: true
})
@@ -153,55 +81,23 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI()
const platform = electron.getPlatform()
const pickGpu = (value: typeof selected.value) => {
const newValue = selected.value === value ? null : value
selected.value = newValue
const showRecommendedBadge = computed(
() => selected.value === 'mps' || selected.value === 'nvidia'
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
cpu: 'cpu',
unsupported: 'manual'
} as const
const descriptionText = computed(() => {
const key = selected.value ? descriptionKeys[selected.value] : undefined
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.p-tag {
--p-tag-gap: 0.5rem;
}
.hover-brighten {
@apply transition-colors;
transition-property: filter, box-shadow;
&:hover {
filter: brightness(107%) contrast(105%);
box-shadow: 0 0 0.25rem #ffffff79;
}
}
.p-accordioncontent-content {
@apply bg-neutral-900 rounded-lg transition-colors;
}
div.selected {
.gpu-button:not(.selected) {
@apply opacity-50 hover:opacity-100;
}
}
.gpu-button {
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
&.selected {
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
}
}
.disabled {
@apply pointer-events-none opacity-40;
}
.p-card-header {
@apply text-center grow;
}
.p-card-body {
@apply text-center pt-0;
}
</style>

View File

@@ -0,0 +1,73 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import HardwareOption from './HardwareOption.vue'
const meta: Meta<typeof HardwareOption> = {
title: 'Desktop/Components/HardwareOption',
component: HardwareOption,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1a1a1a' }]
}
},
argTypes: {
selected: { control: 'boolean' },
imagePath: { control: 'text' },
placeholderText: { control: 'text' },
subtitle: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AppleMetalSelected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
export const AppleMetalUnselected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
export const NvidiaSelected: Story = {
args: {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -0,0 +1,55 @@
<template>
<div class="relative">
<!-- Recommended Badge -->
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@click="$emit('click')"
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
<span v-else class="text-xl font-medium text-neutral-400">
{{ placeholderText }}
</span>
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()
defineEmits<{ click: [] }>()
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<!-- Back button -->
<Button
v-if="currentStep !== '1'"
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
@click="$emit('previous')"
/>
<div v-else></div>
<!-- Step indicators in center -->
<StepList class="flex justify-center items-center gap-3 select-none">
<Step value="1" :pt="stepPassthrough">
{{ $t('install.gpu') }}
</Step>
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
{{ $t('install.installLocation') }}
</Step>
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<!-- Next/Install button -->
<Button
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
:pt="{
label: { class: 'text-neutral-900 font-inter font-black' }
}"
:disabled="!canProceed"
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
/>
</div>
</template>
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{
/** Current step index as string ('1', '2', '3', '4') */
currentStep: string
/** Whether the user can proceed to the next step */
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()
defineEmits<{
previous: []
next: []
install: []
}>()
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
root: { class: 'flex-none p-0 m-0' },
header: ({ context }) => ({
class: [
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
context.active
? 'bg-brand-yellow w-8 rounded-sm'
: 'bg-neutral-700 w-2.5',
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
].join(' ')
}),
number: { class: 'hidden' },
title: { class: 'hidden' }
}
</script>

View File

@@ -0,0 +1,148 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import InstallLocationPicker from './InstallLocationPicker.vue'
const meta: Meta<typeof InstallLocationPicker> = {
title: 'Desktop/Components/InstallLocationPicker',
component: InstallLocationPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
() => {
// Mock electron API
;(window as any).electronAPI = {
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story with accordion expanded
export const Default: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with different background to test transparency
export const OnNeutral900: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-900 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with debug overlay showing background colors
export const DebugBackgrounds: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8 relative">
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
<div>Parent bg: neutral-950 (#0a0a0a)</div>
<div>Accordion content: bg-transparent</div>
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
</div>
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}

View File

@@ -1,103 +1,215 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section -->
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.chooseInstallationLocation') }}
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.installLocationDescription') }}
<p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }}
</p>
<div class="flex gap-2">
<IconField class="flex-1">
<InputText
v-model="installPath"
class="w-full"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<InputIcon
v-tooltip.top="$t('install.installLocationTooltip')"
class="pi pi-info-circle"
/>
</IconField>
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
<!-- Path Input -->
<div class="flex gap-2 px-12">
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<Button
icon="pi pi-folder-open"
severity="secondary"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath"
/>
</div>
<Message v-if="pathError" severity="error" class="whitespace-pre-line">
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
<div class="bg-neutral-800 p-4 rounded-lg">
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
{{ $t('install.systemLocations') }}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-folder text-neutral-400" />
<span class="text-neutral-400">App Data:</span>
<span class="text-neutral-200">{{ appData }}</span>
<span
v-tooltip="$t('install.appDataLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
<div class="flex items-center gap-2">
<i class="pi pi-desktop text-neutral-400" />
<span class="text-neutral-400">App Path:</span>
<span class="text-neutral-200">{{ appPath }}</span>
<span
v-tooltip="$t('install.appPathLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
<!-- Error Messages -->
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
<Message
v-if="pathError"
severity="error"
class="whitespace-pre-line w-full"
>
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Get system paths on component mount
// Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
onMounted(async () => {
const paths = await electron.getSystemPaths()
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
@@ -151,3 +263,52 @@ const onFocus = async () => {
await validatePath(installPath.value)
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
.p-accordionpanel {
@apply border-0 bg-transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
}
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
.p-accordionpanel-active {
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
}
</style>

View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import MigrationPicker from './MigrationPicker.vue'
const meta: Meta<typeof MigrationPicker> = {
title: 'Desktop/Components/MigrationPicker',
component: MigrationPicker,
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' }
]
}
},
decorators: [
() => {
;(window as any).electronAPI = {
validateComfyUISource: () => Promise.resolve({ isValid: true }),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { MigrationPicker },
setup() {
const sourcePath = ref('')
const migrationItemIds = ref<string[]>([])
return { sourcePath, migrationItemIds }
},
template:
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
})
}

View File

@@ -2,10 +2,6 @@
<div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section -->
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.migrateFromExistingInstallation') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.migrationSourcePathDescription') }}
</p>
@@ -13,7 +9,7 @@
<div class="flex gap-2">
<InputText
v-model="sourcePath"
placeholder="Select existing ComfyUI installation (optional)"
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@update:model-value="validateSource"
@@ -27,10 +23,7 @@
</div>
<!-- Migration Options -->
<div
v-if="isValidSource"
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
>
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
<h3 class="text-lg mt-0 font-medium text-neutral-100">
{{ $t('install.selectItemsToMigrate') }}
</h3>

View File

@@ -1,121 +0,0 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
v-tooltip="validationStateTooltip"
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import type { ModelRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex flex-col items-center gap-4">
<div class="w-full">
<h3 class="text-lg font-medium text-neutral-100">
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
<div>
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
{{ $t(`settings.${normalizedSettingId}.name`) }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
<p class="my-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p>
</div>
@@ -16,18 +16,61 @@
"
@state-change="validationState = $event"
/>
<div v-if="secondParagraph" class="mt-2">
<a href="#" @click.prevent="showDialog = true">
{{ $t('g.learnMore') }}
</a>
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
class="select-none max-w-3xl"
>
<div class="text-neutral-300">
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
<div class="mt-2 break-all">
<span class="text-neutral-300 font-semibold">
{{ EXAMPLE_URL_FIRST_PART }}
</span>
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
</div>
<Divider />
<p>
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
</p>
<span class="text-neutral-300 font-semibold">
{{ FILE_URL_SCHEME }}
</span>
<span>
{{ EXAMPLE_FILE_URL }}
</span>
</div>
</Dialog>
</div>
</div>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://'
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
const EXAMPLE_URL_FIRST_PART =
'https://github.com/astral-sh/python-build-standalone/releases/download'
const EXAMPLE_URL_SECOND_PART =
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
const { item } = defineProps<{
item: UVMirror
}>()
@@ -38,11 +81,16 @@ const emit = defineEmits<{
const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE)
const showDialog = ref(false)
const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId)
})
const secondParagraph = computed(() =>
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
)
onMounted(() => {
modelValue.value = item.mirror
})

View File

@@ -1,64 +0,0 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
</div>
</div>
<div class="flex items-center gap-4 mt-2">
<small
v-if="searchQuery && filteredCount !== null"
class="text-color-secondary"
>
{{ $t('g.resultsCount', { count: filteredCount }) }}
</small>
<Button
v-if="searchQuery"
text
size="small"
icon="pi pi-times"
:label="$t('g.clearFilters')"
@click="clearFilters"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const { filteredCount } = defineProps<{
filteredCount?: number | null
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
clearFilters: []
}>()
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
}
</script>

View File

@@ -1,273 +0,0 @@
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 type { TemplateInfo } from '@/platform/workflow/templates/types/template'
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}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
isLoaded: true,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: []
})
})
)
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback: string) => fallback || key
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
getTemplateThumbnailUrl: (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? `/fileURL/templates/${template.name}`
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
const indexSuffix =
sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
},
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
},
getTemplateDescription: (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
},
loadWorkflowTemplate: vi.fn()
})
})
)
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'
)
})
})

View File

@@ -1,139 +0,0 @@
<template>
<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.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
@click="$emit('loadWorkflow', template.name)"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="baseThumbnailSrc" />
</template>
<template v-else-if="template.thumbnailVariant === 'compareSlider'">
<CompareSliderThumbnail
:base-image-src="baseThumbnailSrc"
: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'">
<HoverDissolveThumbnail
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
<DefaultThumbnail
:src="baseThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover'
? UPSCALE_ZOOM_SCALE
: DEFAULT_ZOOM_SCALE
"
/>
</template>
<ProgressSpinner
v-if="loading"
class="absolute inset-0 z-1 w-3/12 h-full"
/>
</div>
</div>
</template>
<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" :title="title">
{{ title }}
</h3>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
const DEFAULT_ZOOM_SCALE = 5
const { sourceModule, loading, template } = defineProps<{
sourceModule: string
categoryTitle: string
loading: boolean
template: TemplateInfo
}>()
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,30 +0,0 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,68 +0,0 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
</span>
</template>
</Column>
<Column field="actions" header="" class="w-12">
<template #body="slotProps">
<Button
icon="pi pi-arrow-right"
text
rounded
size="small"
:loading="loading === slotProps.data.name"
@click="emit('loadWorkflow', slotProps.data.name)"
/>
</template>
</Column>
</DataTable>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { sourceModule, loading, templates } = defineProps<{
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const selectedTemplate = ref(null)
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
const enrichedTemplates = computed(() => {
return templates.map((template) => {
const actualSourceModule = template.sourceModule || sourceModule
return {
...template,
title: getTemplateTitle(template, actualSourceModule),
description: getTemplateDescription(template, actualSourceModule)
}
})
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
</script>

View File

@@ -1,185 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
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('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: ['searchQuery', 'filteredCount'],
emits: ['update:searchQuery', 'clearFilters']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 }
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: `Description for ${name}`
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
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
},
global: {
plugins: [i18n]
}
})
}
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')
})
})

View File

@@ -1,168 +0,0 @@
<template>
<DataView
:value="displayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<TemplateSearchBar
v-model:search-query="searchQuery"
:filtered-count="filteredCount"
@clear-filters="() => reset()"
/>
</div>
</template>
<template #list="{ items }">
<TemplateWorkflowList
:source-module="sourceModule"
:templates="items"
:loading="loading"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
</template>
<template #grid="{ items }">
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
</div>
</template>
</DataView>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const { t } = useI18n()
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
}>()
const layout = useLocalStorage<'grid' | 'list'>(
'Comfy.TemplateWorkflow.Layout',
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
const templatesRef = computed(() => templates || [])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templatesRef)
// When searching, show all results immediately without pagination
// When not searching, use lazy pagination
const shouldUsePagination = computed(() => !searchQuery.value.trim())
// Lazy pagination setup using filtered templates
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset
} = useLazyPagination(filteredTemplates, {
itemsPerPage: 12
})
// Final templates to display
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates, searchQuery], () => {
reset()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const onLoadWorkflow = (name: string) => {
emit('loadWorkflow', name)
}
</script>

View File

@@ -1,112 +0,0 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
data-testid="template-workflows-content"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<Divider
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
/>
<div class="flex flex-1 relative overflow-hidden">
<aside
v-if="isSideNavOpen"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
}
</script>

View File

@@ -1,7 +0,0 @@
<template>
<div>
<h3 class="px-4">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>
</template>

View File

@@ -1,50 +0,0 @@
<template>
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
:options="tabs"
option-group-label="label"
option-label="localizedTitle"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
list-style="max-height:unset"
@update:model-value="handleTabSelection"
>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
</div>
</template>
</Listbox>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import type {
TemplateGroup,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
defineProps<{
tabs: TemplateGroup[]
selectedTab: WorkflowTemplates | null
}>()
const emit = defineEmits<{
(e: 'update:selectedTab', tab: WorkflowTemplates): void
}>()
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -1,6 +1,12 @@
<template>
<BaseThumbnail>
<div class="w-full h-full flex items-center justify-center p-4">
<div
class="w-full h-full flex items-center justify-center p-4"
:style="{
backgroundImage: 'url(/assets/images/default-template.png)',
backgroundRepeat: 'round'
}"
>
<audio controls class="w-full relative" :src="src" @click.stop />
</div>
</BaseThumbnail>

View File

@@ -51,8 +51,9 @@ describe('BaseThumbnail', () => {
vm.error = true
await nextTick()
expect(wrapper.find('.pi-file').exists()).toBe(true)
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
expect(
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
).toBe(true)
})
it('applies transition classes to content', () => {

View File

@@ -1,5 +1,7 @@
<template>
<div class="relative w-64 h-64 rounded-t-lg overflow-hidden select-none">
<div
class="relative w-full aspect-square rounded-t-lg overflow-hidden select-none"
>
<div
v-if="!error"
ref="contentRef"
@@ -11,7 +13,11 @@
<slot />
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<i class="pi pi-file text-4xl" />
<img
src="/assets/images/default-template.png"
draggable="false"
class="transform-gpu transition-transform duration-300 ease-out w-full h-full object-cover"
/>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab"
class="flex p-2 gap-2 workflow-tab group"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@@ -11,9 +11,13 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<span v-if="shouldShowStatusIndicator" class="status-indicator"></span>
<span
v-if="shouldShowStatusIndicator"
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
></span
>
<Button
class="close-button p-0 w-auto"
class="close-button p-0 w-auto invisible"
icon="pi pi-times"
text
severity="secondary"
@@ -174,18 +178,6 @@ onUnmounted(() => {
})
</script>
<style scoped>
@reference '../../assets/css/style.css';
.status-indicator {
@apply absolute font-bold;
font-size: 1.5rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<style>
.p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important;

View File

@@ -358,14 +358,6 @@ onUpdated(() => {
@apply visible;
}
:deep(.p-togglebutton:hover) .status-indicator {
@apply hidden;
}
:deep(.p-togglebutton) .close-button {
@apply invisible;
}
:deep(.p-scrollpanel-content) {
@apply h-full;
}

View File

@@ -1,5 +1,5 @@
<template>
<i :class="icon" class="text-xs text-neutral" />
<i :class="icon" class="text-sm text-neutral" />
</template>
<script setup lang="ts">

View File

@@ -1,13 +1,55 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
<div
:class="
cn(
'flex items-center justify-between m-0 px-3 py-0 pt-5',
collapsible && 'cursor-pointer select-none'
)
"
@click="collapsible && toggleCollapse()"
>
{{ title }}
</h3>
<h3
class="text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>
<i
v-if="collapsible"
:class="
cn(
'pi transition-transform duration-200 text-xs text-neutral-400 dark-theme:text-neutral-400',
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
)
"
/>
</div>
</template>
<script setup lang="ts">
const { title } = defineProps<{
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
title,
modelValue = false,
collapsible = false
} = defineProps<{
title: string
modelValue?: boolean
collapsible?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isCollapsed = computed({
get: () => modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
</script>

View File

@@ -7,19 +7,27 @@
<slot name="header-title"></slot>
</PanelHeader>
<nav class="flex-1 px-3 py-4 flex flex-col gap-1">
<nav
class="flex-1 px-3 py-4 flex flex-col gap-1 overflow-y-auto scrollbar-hide"
>
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle :title="item.title" />
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
@@ -36,7 +44,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
@@ -53,6 +61,9 @@ const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
// Track collapsed state for each group
const collapsedGroups = ref<Record<string, boolean>>({})
const getFirstItemId = () => {
if (!navItems || navItems.length === 0) {
return null

View File

@@ -9,7 +9,8 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon()
const terminal = markRaw(
new Terminal({
convertEol: true
convertEol: true,
theme: { background: '#171717' }
})
)
terminal.loadAddon(fitAddon)

View File

@@ -1,136 +0,0 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called when sync stops
*/
onStop?: () => void
}
interface CanvasTransform {
scale: number
offsetX: number
offsetY: number
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
*
* The sync function typically reads canvas.ds properties like offset and scale to keep
* Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const syncWithCanvas = (canvas: LGraphCanvas) => {
* canvas.ds.scale
* canvas.ds.offset
* }
*
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* syncWithCanvas,
* {
* autoStart: false,
* onStart: () => emit('rafStatusChange', true),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
syncFn: (canvas: LGraphCanvas) => void,
options: CanvasTransformSyncOptions = {}
) {
const { onStart, onStop, autoStart = true } = options
const { getCanvas } = useCanvasStore()
const isActive = ref(false)
let rafId: number | null = null
let lastTransform: CanvasTransform = {
scale: 0,
offsetX: 0,
offsetY: 0
}
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
const ds = canvas.ds
return (
ds.scale !== lastTransform.scale ||
ds.offset[0] !== lastTransform.offsetX ||
ds.offset[1] !== lastTransform.offsetY
)
}
const sync = () => {
if (!isActive.value) return
const canvas = getCanvas()
if (!canvas) return
try {
// Only run sync if transform actually changed
if (hasTransformChanged(canvas)) {
lastTransform = {
scale: canvas.ds.scale,
offsetX: canvas.ds.offset[0],
offsetY: canvas.ds.offset[1]
}
syncFn(canvas)
}
} catch (error) {
console.error('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
const startSync = () => {
if (isActive.value) return
isActive.value = true
onStart?.()
// Reset last transform to force initial sync
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
sync()
}
const stopSync = () => {
isActive.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
onStop?.()
}
onUnmounted(stopSync)
if (autoStart) {
startSync()
}
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,7 +1,7 @@
import { useRafFn } from '@vueuse/core'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
@@ -128,9 +128,7 @@ export function useSelectionToolboxPosition(
}
// Sync with canvas transform
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, {
autoStart: false
})
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
// Watch for selection changes
watch(

View File

@@ -2,42 +2,15 @@
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { nextTick, reactive } from 'vue'
import { reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
spatialIndex?: QuadTree<string>
}
interface PerformanceMetrics {
fps: number
frameTime: number
updateTime: number
nodeCount: number
culledCount: number
callbackUpdateCount: number
rafUpdateCount: number
adaptiveQuality: boolean
}
export interface SafeWidgetData {
name: string
type: string
@@ -63,39 +36,15 @@ export interface VueNodeData {
}
}
interface SpatialMetrics {
queryTime: number
nodesInIndex: number
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
setupEventListeners(): () => void
cleanup(): void
// Update methods
scheduleUpdate(
nodeId?: string,
priority?: 'critical' | 'normal' | 'low'
): void
forceSync(): void
// Spatial queries
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
// Debug
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
@@ -103,61 +52,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy data that auto-GCs when nodes are removed
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Performance tracking
const performanceMetrics = reactive<PerformanceMetrics>({
fps: 0,
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
callbackUpdateCount: 0,
rafUpdateCount: 0,
adaptiveQuality: false
})
// Spatial indexing using QuadTree
const spatialIndex = new QuadTree<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
queryTime: 0,
nodesInIndex: 0
})
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
// Change detection state
const lastNodesSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high',
spatialIndex: undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -278,7 +176,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
widgets: updatedWidgets
})
performanceMetrics.callbackUpdateCount++
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
@@ -348,70 +245,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
}
// Uncomment when needed for future features
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
// let metadata = nodeMetadata.get(node)
// if (!metadata) {
// attachMetadata(node)
// metadata = nodeMetadata.get(node)!
// }
// return metadata
// }
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(() => flush(), 4)
}
}
}
const flush = () => {
const startTime = performance.now()
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Sync with graph state
syncWithGraph()
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
}
const syncWithGraph = () => {
if (!graph?._nodes) return
@@ -422,9 +255,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
lastNodesSnapshot.delete(id)
spatialIndex.remove(id)
}
}
@@ -440,47 +270,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
if (!nodeState.has(id)) {
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
attachMetadata(node)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
}
})
// Update performance metrics
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(s) => s.culled
).length
}
// Most performant: Direct position sync without re-setting entire node
// Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
const startTime = performance.now()
// Use QuadTree for fast spatial query
const results: string[] = spatialIndex.query(viewportBounds)
const visibleIds = new Set(results)
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
return visibleIds
}
/**
@@ -502,30 +292,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
attachMetadata(node)
// Add to spatial index for viewport culling with final positions
const nodeBounds: Bounds = {
x: nodePosition.x,
y: nodePosition.y,
width: nodeSize.width,
height: nodeSize.height
}
spatialIndex.insert(id, nodeBounds, id)
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
@@ -569,9 +340,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = String(node.id)
// Remove from spatial index
spatialIndex.remove(id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
@@ -579,8 +347,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
lastNodesSnapshot.delete(id)
// Call original callback if provided
if (originalCallback) {
@@ -602,21 +368,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
// Clear pending updates
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
nodeState.clear()
lastNodesSnapshot.clear()
pendingUpdates.clear()
criticalUpdates.clear()
lowPriorityUpdates.clear()
spatialIndex.clear()
}
}
@@ -712,15 +466,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return {
vueNodeData,
nodeState,
getNode,
setupEventListeners,
cleanup,
scheduleUpdate,
forceSync: syncWithGraph,
getVisibleNodeIds,
performanceMetrics,
spatialMetrics,
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
cleanup
}
}

View File

@@ -1,20 +1,9 @@
/**
* Vue Node Lifecycle Management Composable
*
* Handles the complete lifecycle of Vue node rendering system including:
* - Node manager initialization and cleanup
* - Layout store synchronization
* - Slot and link sync management
* - Reactive state management for node data, positions, and sizes
* - Memory management and proper cleanup
*/
import { createSharedComposable } from '@vueuse/core'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { readonly, ref, shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
GraphNodeManager,
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
@@ -42,13 +31,10 @@ function useVueNodeLifecycleIndividual() {
// Vue node data state
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
// Trigger for forcing computed re-evaluation
const nodeDataTrigger = ref(0)
const isNodeManagerReady = computed(() => nodeManager.value !== null)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
@@ -61,7 +47,6 @@ function useVueNodeLifecycleIndividual() {
// Use the manager's data maps
vueNodeData.value = manager.vueNodeData
nodeState.value = manager.nodeState
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
@@ -124,7 +109,6 @@ function useVueNodeLifecycleIndividual() {
// Reset reactive maps to clean state
vueNodeData.value = new Map()
nodeState.value = new Map()
}
// Watch for Vue nodes enabled state changes
@@ -218,10 +202,7 @@ function useVueNodeLifecycleIndividual() {
return {
vueNodeData,
nodeState,
nodeDataTrigger: readonly(nodeDataTrigger),
nodeManager: readonly(nodeManager),
isNodeManagerReady,
// Lifecycle methods
initializeNodeManager,

View File

@@ -51,6 +51,8 @@ import {
} from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
@@ -264,7 +266,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-folder-open',
label: 'Browse Templates',
function: () => {
dialogService.showTemplateWorkflowsDialog()
useWorkflowTemplateSelectorDialog().show()
}
},
{

View File

@@ -1,57 +1,205 @@
import { refDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
// @ts-expect-error unused (To be used later?)
interface TemplateFilterOptions {
searchQuery?: string
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const selectedModels = ref<string[]>([])
const selectedUseCases = ref<string[]>([])
const selectedLicenses = ref<string[]>([])
const sortBy = ref<
'default' | 'alphabetical' | 'newest' | 'model-size-low-to-high'
>('newest')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
const filteredTemplates = computed(() => {
const templateData = templatesArray.value
if (templateData.length === 0) {
return []
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.2 },
{ name: 'tags', weight: 0.1 },
{ name: 'models', weight: 0.1 }
],
threshold: 0.4,
includeScore: true,
includeMatches: true
}
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
const availableModels = computed(() => {
const modelSet = new Set<string>()
templatesArray.value.forEach((template) => {
if (Array.isArray(template.models)) {
template.models.forEach((model) => modelSet.add(model))
}
})
return Array.from(modelSet).sort()
})
const availableUseCases = computed(() => {
const tagSet = new Set<string>()
templatesArray.value.forEach((template) => {
if (template.tags && Array.isArray(template.tags)) {
template.tags.forEach((tag) => tagSet.add(tag))
}
})
return Array.from(tagSet).sort()
})
const availableLicenses = computed(() => {
return ['Open Source', 'Closed Source (API Nodes)']
})
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return templatesArray.value
}
if (!searchQuery.value.trim()) {
return templateData
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
})
const filteredByModels = computed(() => {
if (selectedModels.value.length === 0) {
return filteredBySearch.value
}
const query = searchQuery.value.toLowerCase().trim()
return templateData.filter((template) => {
const searchableText = [
template.name,
template.description,
template.sourceModule
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
return filteredBySearch.value.filter((template) => {
if (!template.models || !Array.isArray(template.models)) {
return false
}
return selectedModels.value.some((selectedModel) =>
template.models?.includes(selectedModel)
)
})
})
const filteredByUseCases = computed(() => {
if (selectedUseCases.value.length === 0) {
return filteredByModels.value
}
return filteredByModels.value.filter((template) => {
if (!template.tags || !Array.isArray(template.tags)) {
return false
}
return selectedUseCases.value.some((selectedTag) =>
template.tags?.includes(selectedTag)
)
})
})
const filteredByLicenses = computed(() => {
if (selectedLicenses.value.length === 0) {
return filteredByUseCases.value
}
return filteredByUseCases.value.filter((template) => {
// Check if template has API in its tags or name (indicating it's a closed source API node)
const isApiTemplate =
template.tags?.includes('API') ||
template.name?.toLowerCase().includes('api_')
return selectedLicenses.value.some((selectedLicense) => {
if (selectedLicense === 'Closed Source (API Nodes)') {
return isApiTemplate
} else if (selectedLicense === 'Open Source') {
return !isApiTemplate
}
return false
})
})
})
const sortedTemplates = computed(() => {
const templates = [...filteredByLicenses.value]
switch (sortBy.value) {
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
})
case 'newest':
return templates.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
case 'model-size-low-to-high':
return templates.sort((a: any, b: any) => {
const sizeA =
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
if (sizeA === sizeB) return 0
return sizeA - sizeB
})
case 'default':
default:
// Keep original order (default order)
return templates
}
})
const filteredTemplates = computed(() => sortedTemplates.value)
const resetFilters = () => {
searchQuery.value = ''
selectedModels.value = []
selectedUseCases.value = []
selectedLicenses.value = []
sortBy.value = 'default'
}
const removeModelFilter = (model: string) => {
selectedModels.value = selectedModels.value.filter((m) => m !== model)
}
const removeUseCaseFilter = (tag: string) => {
selectedUseCases.value = selectedUseCases.value.filter((t) => t !== tag)
}
const removeLicenseFilter = (license: string) => {
selectedLicenses.value = selectedLicenses.value.filter((l) => l !== license)
}
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
return {
// State
searchQuery,
selectedModels,
selectedUseCases,
selectedLicenses,
sortBy,
// Computed
filteredTemplates,
availableModels,
availableUseCases,
availableLicenses,
filteredCount,
resetFilters
totalCount,
// Methods
resetFilters,
removeModelFilter,
removeUseCaseFilter,
removeLicenseFilter
}
}

View File

@@ -0,0 +1,38 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-workflow-template-selector'
export const useWorkflowTemplateSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,
props: {
onClose: hide
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' },
root: {
style:
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
}
}
}
})
}
return {
show,
hide
}
}

View File

@@ -3901,6 +3901,19 @@ class UIManager {
this.paintBucketSettingsHTML.style.display = 'none'
}
}
if (tool === Tools.MaskColorFill) {
this.brushSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'flex'
this.paintBucketSettingsHTML.style.display = 'none'
} else if (tool === Tools.MaskBucket) {
this.brushSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'flex'
} else {
this.brushSettingsHTML.style.display = 'flex'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'none'
}
this.messageBroker.publish('setTool', tool)
this.onToolChange()
const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet

View File

@@ -4032,6 +4032,18 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes`
const newPositions = created.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: node.size?.[1] ?? 200
}
}))
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)
graph.afterChange()

View File

@@ -82,6 +82,7 @@ export interface Positionable extends Parent<Positionable>, HasBoundingRect {
* @default 0,0
*/
readonly pos: Point
readonly size?: Size
/** true if this object is part of the selection, otherwise false. */
selected?: boolean

View File

@@ -18,6 +18,7 @@
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
"loadAllFolders": "Load All Folders",
"logoAlt": "ComfyUI Logo",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"terminal": "Terminal",
@@ -168,6 +169,7 @@
"nodesRunning": "nodes running",
"duplicate": "Duplicate",
"moreWorkflows": "More workflows",
"seeTutorial": "See a tutorial",
"nodeRenderError": "Node Render Error",
"nodeContentError": "Node Content Error",
"nodeHeaderError": "Node Header Error",
@@ -406,6 +408,27 @@
"migration": "Migration",
"desktopSettings": "Desktop Settings",
"chooseInstallationLocation": "Choose Installation Location",
"gpuPicker": {
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
"locationPicker": {
"title": "Choose where to install ComfyUI",
"subtitle": "Pick a folder for ComfyUI's files. We'll also set up Python there automatically.",
"pathPlaceholder": "/Users/username/Documents/ComfyUI",
"migrationPathPlaceholder": "Select existing ComfyUI installation (optional)",
"migrateFromExisting": "Migrate from existing installation",
"migrateDescription": "Copy or link your existing models, custom nodes, and configurations from a previous ComfyUI installation.",
"chooseDownloadServers": "Choose download servers manually",
"downloadServersDescription": "Select specific mirror servers for downloading Python, PyPI packages, and PyTorch based on your location."
},
"systemLocations": "System Locations",
"failedToSelectDirectory": "Failed to select directory",
"pathValidationFailed": "Failed to validate path",
@@ -490,18 +513,26 @@
"metricsDisabled": "Metrics Disabled",
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
},
"desktopStart": {
"initialising": "Initialising..."
},
"serverStart": {
"title": "Starting ComfyUI",
"troubleshoot": "Troubleshoot",
"reportIssue": "Report Issue",
"openLogs": "Open Logs",
"showTerminal": "Show Terminal",
"copySelectionTooltip": "Copy selection",
"copyAllTooltip": "Copy all",
"errorMessage": "Unable to start ComfyUI Desktop",
"installation": {
"title": "Installing ComfyUI"
},
"process": {
"initial-state": "Loading...",
"python-setup": "Setting up Python Environment...",
"starting-server": "Starting ComfyUI server...",
"ready": "Finishing...",
"ready": "Loading Human Interface",
"error": "Unable to start ComfyUI Desktop"
}
},
@@ -655,6 +686,7 @@
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
"Basics": "Basics",
"GettingStarted": "Getting Started",
"Flux": "Flux",
"ControlNet": "ControlNet",
"Upscaling": "Upscaling",
@@ -663,6 +695,7 @@
"Area Composition": "Area Composition",
"3D": "3D",
"Audio": "Audio",
"LLMs": "LLMs",
"Image API": "Image API",
"Video API": "Video API",
"LLM API": "LLM API",
@@ -971,6 +1004,24 @@
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
}
},
"categories": "Categories",
"resetFilters": "Clear Filters",
"sorting": "Sort by",
"activeFilters": "Filters:",
"loading": "Loading templates...",
"noResults": "No templates found",
"noResultsHint": "Try adjusting your search or filters",
"modelFilter": "Model Filter",
"modelsSelected": "{count} Models",
"useCasesSelected": "{count} Use Cases",
"licensesSelected": "{count} Licenses",
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search..."
}
},
"graphCanvasMenu": {

View File

@@ -6,12 +6,15 @@
"name": "Send anonymous usage metrics"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror",
"name": "PyPI Install Mirror",
"tooltip": "Default pip install mirror"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. To use a different source for Python installations, enter a mirror URL.",
"urlFormatTitle": "Mirror URL Format",
"urlDescription": "This is an example python download URL.\n\nThe mirror URL is the first half, including everything before the date (20250902):",
"fileUrlDescription": "To install from a file you downloaded earlier, you may use a file URL:"
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror",
@@ -421,4 +424,4 @@
"pysssss_SnapToGrid": {
"name": "Always snap to grid"
}
}
}

View File

@@ -2,11 +2,6 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import {
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
} from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -14,7 +9,7 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuthWithDependencies } from 'vuefire'
import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
@@ -71,18 +66,6 @@ app
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [
// Configure Firebase Auth persistence: localStorage first, IndexedDB last.
// Localstorage is preferred to IndexedDB for mobile Safari compatibility.
VueFireAuthWithDependencies({
dependencies: {
persistence: [
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
]
}
})
]
modules: [VueFireAuth()]
})
.mount('#vue-app')

View File

@@ -60,7 +60,7 @@ export function useTemplateWorkflows() {
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
index = '1'
) => {
const basePath =
sourceModule === 'default'
@@ -85,13 +85,12 @@ export function useTemplateWorkflows() {
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
const getTemplateDescription = (template: TemplateInfo) => {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}
/**

View File

@@ -1,23 +1,28 @@
import { groupBy } from 'es-toolkit/compat'
import Fuse from 'fuse.js'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { st } from '@/i18n'
import { i18n, st } from '@/i18n'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { getCategoryIcon } from '@/utils/categoryIcons'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { normalizeI18nKey } from '@/utils/formatUtil'
} from '../types/template'
const SHOULD_SORT_CATEGORIES = new Set([
// API Node templates should be strictly sorted by name to avoid any
// favoritism or bias towards a particular API. Other categories can
// have their ordering specified in index.json freely.
'Image API',
'Video API'
])
// Enhanced template interface for easier filtering
interface EnhancedTemplate extends TemplateInfo {
sourceModule: string
category?: string
categoryType?: string
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
isEssential?: boolean
searchableText?: string
}
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
@@ -26,36 +31,13 @@ export const useWorkflowTemplatesStore = defineStore(
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
/**
* Sort a list of templates in alphabetical order by localized display name.
*/
const sortTemplateList = (templates: TemplateInfo[]) =>
templates.sort((a, b) => {
const aName = st(
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
a.title ?? a.name
)
const bName = st(
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
b.name
)
return aName.localeCompare(bName)
})
// Store filter mappings for dynamic categories
type FilterData = {
category?: string
categoryGroup?: string
}
/**
* Sort any template categories (grouped templates) that should be sorted.
* Leave other categories' templates in their original order specified in index.json
*/
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
categories.map((category) => {
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
return {
...category,
templates: sortTemplateList(category.templates)
}
}
return category
})
const categoryFilters = ref(new Map<string, FilterData>())
/**
* Add localization fields to a template.
@@ -144,12 +126,13 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
/**
* Original grouped templates for backward compatibility
*/
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
),
...coreTemplates.value.map(localizeTemplateCategory),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
@@ -169,38 +152,286 @@ export const useWorkflowTemplatesStore = defineStore(
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
const groupedByCategory = [
{
label: st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
),
modules: [
createAllCategory(),
...allTemplates.filter((t) => t.moduleName === 'default')
]
},
...(Object.keys(customTemplates.value).length > 0
? [
{
label: st(
'templateWorkflows.category.Custom Nodes',
'Custom Nodes'
),
modules: allTemplates.filter((t) => t.moduleName !== 'default')
}
]
: [])
]
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
return groupedByCategory
})
/**
* Enhanced templates with proper categorization for filtering
*/
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
const allTemplates: EnhancedTemplate[] = []
// Process core templates
coreTemplates.value.forEach((category) => {
category.templates.forEach((template) => {
const enhancedTemplate: EnhancedTemplate = {
...template,
sourceModule: category.moduleName,
category: category.title,
categoryType: category.type,
categoryGroup: category.category,
isEssential: category.isEssential,
searchableText: [
template.title || template.name,
template.description || '',
category.title,
...(template.tags || []),
...(template.models || [])
].join(' ')
}
allTemplates.push(enhancedTemplate)
})
})
// Process custom templates
Object.entries(customTemplates.value).forEach(
([moduleName, templates]) => {
templates.forEach((name) => {
const enhancedTemplate: EnhancedTemplate = {
name,
title: name,
description: name,
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: moduleName,
category: 'Extensions',
categoryType: 'extension',
searchableText: `${name} ${moduleName} extension`
}
allTemplates.push(enhancedTemplate)
})
}
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
return allTemplates
})
/**
* Fuse.js instance for advanced template searching and filtering
*/
const templateFuse = computed(() => {
const fuseOptions = {
keys: [
{ name: 'searchableText', weight: 0.4 },
{ name: 'title', weight: 0.3 },
{ name: 'name', weight: 0.2 },
{ name: 'tags', weight: 0.1 }
],
threshold: 0.3,
includeScore: true
}
return new Fuse(enhancedTemplates.value, fuseOptions)
})
/**
* Filter templates by category ID using stored filter mappings
*/
const filterTemplatesByCategory = (categoryId: string) => {
if (categoryId === 'all') {
return enhancedTemplates.value
}
if (categoryId === 'basics') {
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter((t) => t.isEssential)
}
// Handle extension-specific filters
if (categoryId.startsWith('extension-')) {
const moduleName = categoryId.replace('extension-', '')
return enhancedTemplates.value.filter(
(t) => t.sourceModule === moduleName
)
}
return groupedByCategory
// Look up the filter from our stored mappings
const filter = categoryFilters.value.get(categoryId)
if (!filter) {
return enhancedTemplates.value
}
// Apply the filter
return enhancedTemplates.value.filter((template) => {
if (filter.category && template.category !== filter.category) {
return false
}
if (
filter.categoryGroup &&
template.categoryGroup !== filter.categoryGroup
) {
return false
}
return true
})
}
/**
* New navigation structure dynamically built from JSON categories
*/
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
if (!isLoaded.value) return []
const items: (NavItemData | NavGroupData)[] = []
// Clear and rebuild filter mappings
categoryFilters.value.clear()
// 1. All Templates - always first
items.push({
id: 'all',
label: st('templateWorkflows.category.All', 'All Templates'),
icon: getCategoryIcon('all')
})
// 2. Basics (isEssential categories) - always second if it exists
let gettingStartedText = 'Getting Started'
const essentialCat = coreTemplates.value.find(
(cat) => cat.isEssential && cat.templates.length > 0
)
const hasEssentialCategories = Boolean(essentialCat)
if (essentialCat) {
gettingStartedText = essentialCat.title
}
if (hasEssentialCategories) {
items.push({
id: 'basics',
label: gettingStartedText,
icon: 'icon-[lucide--graduation-cap]'
})
}
// 3. Group categories from JSON dynamically
const categoryGroups = new Map<
string,
{ title: string; items: NavItemData[] }
>()
// Process all categories from JSON
coreTemplates.value.forEach((category) => {
// Skip essential categories as they're handled as Basics
if (category.isEssential) return
const categoryGroup = category.category
const categoryIcon = category.icon
if (categoryGroup) {
if (!categoryGroups.has(categoryGroup)) {
categoryGroups.set(categoryGroup, {
title: categoryGroup,
items: []
})
}
const group = categoryGroups.get(categoryGroup)!
// Generate unique ID for this category
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
// Store the filter mapping
categoryFilters.value.set(categoryId, {
category: category.title,
categoryGroup: categoryGroup
})
group.items.push({
id: categoryId,
label: st(
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
category.title
),
icon: categoryIcon || getCategoryIcon(category.type || 'default')
})
}
})
// Add grouped categories
categoryGroups.forEach((group, groupName) => {
if (group.items.length > 0) {
items.push({
title: st(
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
groupName
.split(' ')
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ')
),
items: group.items
})
}
})
// 4. Extensions - always last
const extensionCounts = enhancedTemplates.value.filter(
(t) => t.sourceModule !== 'default'
).length
if (extensionCounts > 0) {
// Get unique extension modules
const extensionModules = Array.from(
new Set(
enhancedTemplates.value
.filter((t) => t.sourceModule !== 'default')
.map((t) => t.sourceModule)
)
).sort()
const extensionItems: NavItemData[] = extensionModules.map(
(moduleName) => ({
id: `extension-${moduleName}`,
label: st(
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
moduleName
),
icon: getCategoryIcon('extensions')
})
)
items.push({
title: st('templateWorkflows.category.Extensions', 'Extensions'),
items: extensionItems,
collapsible: true
})
}
return items
})
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
coreTemplates.value = await api.getCoreWorkflowTemplates()
const locale = i18n.global.locale.value
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
isLoaded.value = true
}
} catch (error) {
@@ -210,6 +441,10 @@ export const useWorkflowTemplatesStore = defineStore(
return {
groupedTemplates,
navGroupedTemplates,
enhancedTemplates,
templateFuse,
filterTemplatesByCategory,
isLoaded,
loadWorkflowTemplates
}

View File

@@ -11,13 +11,25 @@ export interface TemplateInfo {
description: string
localizedTitle?: string
localizedDescription?: string
isEssential?: boolean
sourceModule?: string
tags?: string[]
models?: string[]
date?: string
useCase?: string
license?: string
size?: number
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
localizedTitle?: string
category?: string
type?: string
icon?: string
isEssential?: boolean
}
export interface TemplateGroup {

View File

@@ -1379,6 +1379,7 @@ class LayoutStoreImpl implements LayoutStore {
this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds)
ynode.set('position', { x: bounds.x, y: bounds.y })
ynode.set('size', { width: bounds.width, height: bounds.height })
}
}, this.currentActor)

View File

@@ -1,7 +1,12 @@
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
class="absolute inset-0 w-full h-full pointer-events-none"
:class="
cn(
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD ? 'isLOD' : ''
)
"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
@@ -11,13 +16,15 @@
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps {
canvas?: LGraphCanvas
@@ -34,6 +41,8 @@ const {
isNodeInViewport
} = useTransformState()
const { isLOD } = useLOD(camera)
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 200,
@@ -59,14 +68,19 @@ const handlePointerDown = (event: PointerEvent) => {
const emit = defineEmits<{
rafStatusChange: [active: boolean]
transformUpdate: [time: number]
transformUpdate: []
}>()
useCanvasTransformSync(props.canvas, syncWithCanvas, {
onStart: () => emit('rafStatusChange', true),
onUpdate: (duration) => emit('transformUpdate', duration),
onStop: () => emit('rafStatusChange', false)
})
useRafFn(
() => {
if (!props.canvas) {
return
}
syncWithCanvas(props.canvas)
emit('transformUpdate')
},
{ immediate: true }
)
</script>
<style scoped>

View File

@@ -1,115 +0,0 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
}
interface CanvasTransformSyncCallbacks {
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called after each sync update with timing information
*/
onUpdate?: (duration: number) => void
/**
* Called when sync stops
*/
onStop?: () => void
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, provides performance timing,
* and ensures proper cleanup.
*
* The sync function typically reads canvas.ds (draw state) properties like offset and scale
* to keep Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* canvas,
* (canvas) => syncWithCanvas(canvas),
* {
* onStart: () => emit('rafStatusChange', true),
* onUpdate: (time) => emit('transformUpdate', time),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
canvas: LGraphCanvas | undefined | null,
syncFn: (canvas: LGraphCanvas) => void,
callbacks: CanvasTransformSyncCallbacks = {},
options: CanvasTransformSyncOptions = {}
) {
const { autoStart = true } = options
const { onStart, onUpdate, onStop } = callbacks
const isActive = ref(false)
let rafId: number | null = null
const startSync = () => {
if (isActive.value || !canvas) return
isActive.value = true
onStart?.()
const sync = () => {
if (!isActive.value || !canvas) return
try {
const startTime = performance.now()
syncFn(canvas)
const endTime = performance.now()
onUpdate?.(endTime - startTime)
} catch (error) {
console.warn('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
sync()
}
const stopSync = () => {
if (!isActive.value) return
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
isActive.value = false
onStop?.()
}
// Auto-start if canvas is available and autoStart is enabled
if (autoStart && canvas) {
startSync()
}
// Clean up on unmount
onUnmounted(() => {
stopSync()
})
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,7 +1,7 @@
import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
@@ -124,9 +124,8 @@ export function useMinimapViewport(
c.setDirty(true, true)
}
const { startSync: startViewportSync, stopSync: stopViewportSync } =
useCanvasTransformSync(updateViewport, { autoStart: false })
const { resume: startViewportSync, pause: stopViewportSync } =
useRafFn(updateViewport)
return {
bounds: computed(() => bounds.value),

View File

@@ -35,7 +35,7 @@
v-else
:src="currentImageUrl"
:alt="imageAltText"
class="w-full h-[352px] object-cover block"
class="w-full h-[352px] object-contain block"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -94,17 +94,20 @@
</div>
</div>
<!-- Image Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
<div class="relative">
<!-- Image Dimensions -->
<div class="text-white text-xs text-center mt-2">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-gray-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
</div>
</div>
</template>
@@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
interface ImagePreviewProps {
/** Array of image URLs to display */
readonly imageUrls: readonly string[]

View File

@@ -10,12 +10,15 @@
/>
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<div class="relative">
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div>
</template>
@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {

View File

@@ -23,7 +23,7 @@
bypassed,
'will-change-transform': isDragging
},
lodCssClass,
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -48,10 +48,9 @@
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, lodLevel, isCollapsed]"
v-memo="[nodeData.title, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@@ -60,9 +59,7 @@
</div>
<div
v-if="
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
@@ -72,7 +69,7 @@
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isMinimalLOD && !isCollapsed">
<template v-if="!isCollapsed">
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
@@ -96,28 +93,24 @@
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-if="shouldRenderSlots"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="shouldShowWidgets"
v-memo="[nodeData.widgets?.length, lodLevel]"
v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="shouldShowContent"
v-if="hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
@@ -152,7 +145,6 @@ import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/compo
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -180,8 +172,7 @@ interface LGraphNodeProps {
const {
nodeData,
error = null,
readonly = false,
zoomLevel = 1
readonly = false
} = defineProps<LGraphNodeProps>()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
@@ -218,18 +209,6 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(() => zoomLevel)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
@@ -271,28 +250,17 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = cn(
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
)
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
{
isMinimalLOD,
isCollapsed
}
)
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && nodeData.widgets?.length
)
const shouldShowContent = computed(
() => shouldRenderContent.value && hasCustomContent.value
)
const borderClass = computed(() => {
if (hasAnyError.value) {
return 'border-error'

View File

@@ -0,0 +1,3 @@
<template>
<div class="lod-fallback absolute inset-0 w-full h-full bg-zinc-800"></div>
</template>

View File

@@ -21,7 +21,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import ImagePreview from './ImagePreview.vue'
@@ -29,7 +28,6 @@ interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
imageUrls?: string[]
}

View File

@@ -4,41 +4,44 @@
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
class="lg-node-header p-4 rounded-t-2xl cursor-move"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
<div class="flex items-center justify-between relative">
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center lod-toggle"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
></i>
</button>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1"
data-testid="node-title"
>
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1 lod-toggle"
data-testid="node-title"
>
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
<LODFallback />
</div>
<!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center">
<div v-if="!readonly" class="flex items-center lod-toggle">
<IconButton
v-if="isSubgraphNode"
size="sm"
@@ -69,6 +72,8 @@ import {
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import LODFallback from './LODFallback.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
readonly?: boolean

View File

@@ -19,9 +19,10 @@
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
class="lg-widget-container relative flex items-center group"
class="lg-widget-container flex items-center group"
>
<!-- Widget Input Slot Dot -->
<div
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
@@ -61,12 +62,10 @@ import type {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -77,10 +76,9 @@ import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -125,18 +123,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const widgets = nodeData.widgets as SafeWidgetData[]
const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
return []
}
for (const widget of widgets) {
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText
const simplified: SimplifiedWidget = {

View File

@@ -1,14 +1,16 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
>
{{ slotData.name || `Output ${index}` }}
</span>
<div class="relative">
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
>
{{ slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
@@ -38,6 +40,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {

View File

@@ -24,6 +24,7 @@ defineExpose({
>
<div
ref="slot-el"
class="slot-dot"
:style="{ backgroundColor: color }"
:class="
cn(

View File

@@ -1,295 +1,141 @@
# Level of Detail (LOD) Implementation Guide for Widgets
# ComfyUI Widget LOD System: Architecture and Implementation
## What is Level of Detail (LOD)?
## Executive Summary
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
For ComfyUI nodes, this means:
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
## The Two Approaches: Reactive vs. Static LOD
## Why LOD Matters
### Approach 1: Reactive LOD (Original Design)
Without LOD optimization:
- 1000+ nodes with full detail = browser lag and poor performance
- Text that's too small to read still gets rendered (wasted work)
- Visual effects that are invisible at distance still consume GPU
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
With LOD optimization:
- Smooth performance even with large node graphs
- Battery life improvement on laptops
- Better user experience across different zoom levels
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
## How to Implement LOD in Your Widget
### Approach 2: Static LOD with CSS (Current Implementation)
### Step 1: Get the LOD Context
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
```vue
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
## The GPU Texture Bottleneck
const props = defineProps<{
widget: any
zoomLevel: number
// ... other props
}>()
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
// Get LOD information
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
</script>
```
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
### Traditional Assumption
### Step 2: Choose What to Show at Different Zoom Levels
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
#### Understanding the LOD Score
- `lodScore` is a number from 0 to 1
- 0 = completely zoomed out (show minimal detail)
- 1 = fully zoomed in (show everything)
- 0.5 = medium zoom (show some details)
### Actual Browser Behavior
#### Understanding LOD Levels
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
- `'full'` = zoom level 0.8 or above (zoomed in close)
When all nodes are children of a single transformed parent:
### Step 3: Implement Your Widget's LOD Strategy
1. The browser creates one large GPU texture for the entire node graph
2. The texture dimensions are determined by the bounding box of all content
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
Here's a complete example of a slider widget with LOD:
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
```vue
<template>
<div class="number-widget">
<!-- The main control always shows -->
<input
v-model="value"
type="range"
:min="widget.min"
:max="widget.max"
class="widget-slider"
/>
<!-- Show label only when zoomed in enough to read it -->
<label
v-if="showLabel"
class="widget-label"
>
{{ widget.name }}
</label>
<!-- Show precise value only when fully zoomed in -->
<span
v-if="showValue"
class="widget-value"
>
{{ formattedValue }}
</span>
<!-- Show description only at full detail -->
<div
v-if="showDescription && widget.description"
class="widget-description"
>
{{ widget.description }}
</div>
</div>
</template>
## Two Distinct Performance Concerns
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
The analysis reveals two often-conflated performance considerations that should be understood separately:
const props = defineProps<{
widget: any
zoomLevel: number
}>()
### 1. Rendering Performance
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
**Question:** How fast can the browser paint and composite the node graph during interactions?
// Define when to show each element
const showLabel = computed(() => {
// Show label when user can actually read it
return lodScore.value > 0.4 // Roughly 12px+ text size
})
**Traditional thinking:** Show less content → render faster
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
const showValue = computed(() => {
// Show precise value only when zoomed in close
return lodScore.value > 0.7 // User is focused on this specific widget
})
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
const showDescription = computed(() => {
// Description only at full detail
return lodLevel.value === 'full' // Maximum zoom level
})
### 2. Memory and Lifecycle Management
// You can also use LOD for styling
const widgetClasses = computed(() => {
const classes = ['number-widget']
if (lodLevel.value === 'minimal') {
classes.push('widget--minimal')
}
return classes
})
</script>
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
<style scoped>
/* Apply different styles based on LOD */
.widget--minimal {
/* Simplified appearance when zoomed out */
.widget-slider {
height: 4px; /* Thinner slider */
opacity: 0.9;
}
}
This is where unmounting widgets might theoretically help:
/* Normal styling */
.widget-slider {
height: 8px;
transition: height 0.2s ease;
}
- Complex widgets (3D viewers, chart renderers) might hold significant memory
- Event listeners and reactive watchers consume resources
- Some widgets might run background processes or animations
.widget-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
.widget-value {
font-family: monospace;
font-size: 0.7rem;
color: var(--text-accent);
}
## Design Philosophy and Trade-offs
.widget-description {
font-size: 0.6rem;
color: var(--text-muted);
margin-top: 4px;
}
</style>
```
The current CSS-based approach makes several deliberate trade-offs:
## Common LOD Patterns
### What We Optimize For
### Pattern 1: Essential vs. Nice-to-Have
```typescript
// Always show the main functionality
const showMainControl = computed(() => true)
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
3. **Simple widget development** - Widget authors don't need to implement LOD logic
4. **Reliable state preservation** - Widgets never lose state from unmounting
// Granular control with lodScore
const showLabels = computed(() => lodScore.value > 0.4)
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
### What We Accept
// Simple control with lodLevel
const showExtras = computed(() => lodLevel.value === 'full')
```
1. **Higher baseline memory usage** - All widgets remain mounted
2. **Less granular control** - Widgets can't optimize their own LOD behavior
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
### Pattern 2: Smooth Opacity Transitions
```typescript
// Gradually fade elements based on zoom
const labelOpacity = computed(() => {
// Fade in from zoom 0.3 to 0.6
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
})
```
## Open Questions and Future Considerations
### Pattern 3: Progressive Detail
```typescript
const detailLevel = computed(() => {
if (lodScore.value < 0.3) return 'none'
if (lodScore.value < 0.6) return 'basic'
if (lodScore.value < 0.8) return 'standard'
return 'full'
})
```
### Should widgets have any LOD control?
## LOD Guidelines by Widget Type
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
### Text Input Widgets
- **Always show**: The input field itself
- **Medium zoom**: Show label
- **High zoom**: Show placeholder text, validation messages
- **Full zoom**: Show character count, format hints
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
**Current behavior:** Hidden via CSS but still mounted
**Question:** Should such widgets be able to opt into unmounting at distance?
### Button Widgets
- **Always show**: The button
- **Medium zoom**: Show button text
- **High zoom**: Show button description
- **Full zoom**: Show keyboard shortcuts, tooltips
The challenge is that introducing selective unmounting would require:
### Selection Widgets (Dropdown, Radio)
- **Always show**: The current selection
- **Medium zoom**: Show option labels
- **High zoom**: Show all options when expanded
- **Full zoom**: Show option descriptions, icons
- Maintaining widget state across mount/unmount cycles
- Accepting the performance cost of remounting when zooming in
- Adding complexity to the widget API
### Complex Widgets (Color Picker, File Browser)
- **Always show**: Simplified representation (color swatch, filename)
- **Medium zoom**: Show basic controls
- **High zoom**: Show full interface
- **Full zoom**: Show advanced options, previews
### Could we reduce GPU texture size?
## Design Collaboration Guidelines
Since texture dimensions are the limiting factor, could we:
### For Designers
When designing widgets, consider creating variants for different zoom levels:
- Use multiple compositor layers for different regions (chunk the transformpane)?
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
1. **Minimal Design** (far away view)
- Essential elements only
- Higher contrast for visibility
- Simplified shapes and fewer details
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
2. **Standard Design** (normal view)
- Balanced detail and simplicity
- Clear labels and readable text
- Good for most use cases
### Is there a hybrid approach?
3. **Full Detail Design** (close-up view)
- All labels, descriptions, and help text
- Rich visual effects and polish
- Maximum information density
Could we identify specific threshold scenarios where reactive LOD makes sense?
### Design Handoff Checklist
- [ ] Specify which elements are essential vs. nice-to-have
- [ ] Define minimum readable sizes for text elements
- [ ] Provide simplified versions for distant viewing
- [ ] Consider color contrast at different opacity levels
- [ ] Test designs at multiple zoom levels
- When node count is low (< 50 nodes)
- For specifically registered "expensive" widgets
- At extreme zoom levels only
## Testing Your LOD Implementation
## Implementation Guidelines
### Manual Testing
1. Create a workflow with your widget
2. Zoom out until nodes are very small
3. Verify essential functionality still works
4. Zoom in gradually and check that details appear smoothly
5. Test performance with 50+ nodes containing your widget
Given the current architecture, here's how to work within the system:
### Performance Considerations
- Avoid complex calculations in LOD computed properties
- Use `v-if` instead of `v-show` for elements that won't render
- Consider using `v-memo` for expensive widget content
- Test on lower-end devices
### For Widget Developers
### Common Mistakes
**Don't**: Hide the main widget functionality at any zoom level
**Don't**: Use complex animations that trigger at every zoom change
**Don't**: Make LOD thresholds too sensitive (causes flickering)
**Don't**: Forget to test with real content and edge cases
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
3. **Minimize background processing** - Assume your widget is always running
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
**Do**: Keep essential functionality always visible
**Do**: Use smooth transitions between LOD levels
**Do**: Test with varying content lengths and types
**Do**: Consider accessibility at all zoom levels
### For System Architects
## Getting Help
1. **Monitor GPU memory usage** - The single texture approach has memory implications
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
- Ask in the ComfyUI frontend Discord for LOD implementation questions
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
- Profile performance impact using browser dev tools
## Conclusion
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.

View File

@@ -82,7 +82,6 @@ function useNodeEventHandlersIndividual() {
const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) {
node.collapse()
nodeManager.value.scheduleUpdate(nodeId, 'critical')
}
}

View File

@@ -2,186 +2,33 @@
* Level of Detail (LOD) composable for Vue-based node rendering
*
* Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom thresholds to determine
* how much detail to render for each node component.
*
* ## LOD Levels
*
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
*
* ## Performance Benefits
*
* - Reduces DOM element count by up to 80% at low zoom levels
* - Minimizes layout calculations and paint operations
* - Enables smooth performance with 1000+ nodes
* - Maintains visual fidelity when detail is actually visible
*
* @example
* ```typescript
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
*
* // In template
* <NodeWidgets v-if="shouldRenderWidgets" />
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
* performance with large node graphs. Uses zoom threshold based on DPR
* to determine how much detail to render for each node component.
* Default minFontSize = 8px
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
**/
import { useDevicePixelRatio } from '@vueuse/core'
import { computed } from 'vue'
export enum LODLevel {
MINIMAL = 'minimal', // zoom <= 0.4
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
FULL = 'full' // zoom > 0.8
import { useSettingStore } from '@/platform/settings/settingStore'
interface Camera {
z: number // zoom level
}
interface LODConfig {
renderWidgets: boolean
renderSlots: boolean
renderContent: boolean
renderSlotLabels: boolean
renderWidgetLabels: boolean
cssClass: string
}
export function useLOD(camera: Camera) {
const isLOD = computed(() => {
const { pixelRatio } = useDevicePixelRatio()
const baseFontSize = 14
const dprAdjustment = Math.sqrt(pixelRatio.value)
// LOD configuration for each level
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
[LODLevel.FULL]: {
renderWidgets: true,
renderSlots: true,
renderContent: true,
renderSlotLabels: true,
renderWidgetLabels: true,
cssClass: 'lg-node--lod-full'
},
[LODLevel.REDUCED]: {
renderWidgets: true,
renderSlots: true,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-reduced'
},
[LODLevel.MINIMAL]: {
renderWidgets: false,
renderSlots: false,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-minimal'
}
}
const settingStore = useSettingStore()
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
const threshold =
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
/**
* Create LOD (Level of Detail) state based on zoom level
*
* @param zoomRef - Reactive reference to current zoom level (camera.z)
* @returns LOD state and configuration
*/
export function useLOD(zoomRefMaybe: MaybeRefOrGetter<number>) {
const zoomRef = toRef(zoomRefMaybe)
// Continuous LOD score (0-1) for smooth transitions
const lodScore = computed(() => {
const zoom = zoomRef.value
return Math.max(0, Math.min(1, zoom))
return camera.z < threshold
})
// Determine current LOD level based on zoom
const lodLevel = computed<LODLevel>(() => {
const zoom = zoomRef.value
if (zoom > 0.8) return LODLevel.FULL
if (zoom > 0.4) return LODLevel.REDUCED
return LODLevel.MINIMAL
})
// Get configuration for current LOD level
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
// Convenience computed properties for common rendering decisions
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
const shouldRenderSlotLabels = computed(
() => lodConfig.value.renderSlotLabels
)
const shouldRenderWidgetLabels = computed(
() => lodConfig.value.renderWidgetLabels
)
// CSS class for styling based on LOD level
const lodCssClass = computed(() => lodConfig.value.cssClass)
// Get essential widgets for reduced LOD (only interactive controls)
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
if (lodLevel.value === LODLevel.FULL) return widgets
if (lodLevel.value === LODLevel.MINIMAL) return []
// For reduced LOD, filter to essential widget types only
return widgets.filter((widget: any) => {
const type = widget?.type?.toLowerCase()
return [
'combo',
'select',
'toggle',
'boolean',
'slider',
'number'
].includes(type)
})
}
// Performance metrics for debugging
const lodMetrics = computed(() => ({
level: lodLevel.value,
zoom: zoomRef.value,
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
slotCount: shouldRenderSlots.value ? 'full' : 'none'
}))
return {
// Core LOD state
lodLevel: readonly(lodLevel),
lodConfig: readonly(lodConfig),
lodScore: readonly(lodScore),
// Rendering decisions
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels,
// Styling
lodCssClass,
// Utilities
getEssentialWidgets,
lodMetrics
}
}
/**
* Get LOD level thresholds for configuration or debugging
*/
export const LOD_THRESHOLDS = {
FULL_THRESHOLD: 0.8,
REDUCED_THRESHOLD: 0.4,
MINIMAL_THRESHOLD: 0.0
} as const
/**
* Check if zoom level supports a specific feature
*/
export function supportsFeatureAtZoom(
zoom: number,
feature: keyof LODConfig
): boolean {
const level =
zoom > 0.8
? LODLevel.FULL
: zoom > 0.4
? LODLevel.REDUCED
: LODLevel.MINIMAL
return LOD_CONFIGS[level][feature] as boolean
return { isLOD }
}

Some files were not shown because too many files have changed in this diff Show More