Compare commits

...

89 Commits

Author SHA1 Message Date
Alexander Brown
1845708ddb 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-12 18:52:18 -07:00
Alexander Brown
c588f2f457 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-12 14:07:32 -07:00
Benjamin Lu
29ad47d20f 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-12 21:01:52 +00:00
Rizumu Ayaka
9aeeab9a9f feat: remove the divider that between widgets and slots (#5517) 2025-09-12 12:33:02 -07:00
Christian Byrne
6645b3917f Update ApiNodesSignInContent.vue (#5514) 2025-09-12 00:09:04 -07:00
snomiao
4ec6223189 fix: Add JSON import assertions for Node.js ESM compatibility (#5507)
Added `with { type: 'json' }` assertions to all JSON imports to ensure compatibility with Node.js ES modules and Playwright environments. This follows the current ESM specification where JSON imports require explicit type assertions.

Affected areas:
- Tailwind config
- i18n locale imports (36 files)
- Test fixtures and spec files
- API client feature flags
- Core color palettes

References:
- https://nodejs.org/api/esm.html
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import/with

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-11 20:22:21 -07:00
Benjamin Lu
dfcbbec2b9 Use env (#5501) 2025-09-11 17:33:25 -07:00
Johnpaul Chiwetelu
c051c3a507 Drag Multiple Vue Nodes (#5459)
* feat: enhance dragging functionality to support multiple selected nodes

* feat: enhance node selection handling to support drag state detection

* feat: enhance node selection handling to support drag state detection

* fix: update event trigger from pointer down to pointer up in LGraphNode tests
2025-09-11 17:25:23 -07:00
Alexander Brown
54cbf42a84 fix: Missing .value led to the release dot always showing (#5500) 2025-09-12 00:11:55 +00:00
Alexander Brown
ef7575b8d6 Revert "chore(lint): make ESLint concurrency configurable via pnpm config" (#5499)
* Revert "chore(lint): make ESLint concurrency configurable via pnpm config (#5…"

This reverts commit 9997053290.

* chore: Remove --concurrency
2025-09-11 17:07:19 -07:00
AustinMroz
68845ce33a Fix Connection of Primitive nodes to Subgraph node (#5024)
* Fix connection of primitives to subgraphNodes

* Fix loading and nested subgraphs with primitives

Medium hackyness, but this saves ~100 lines.

* Use improved type check

* Remove requirement for type assertion

* Add warning comment

---------

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-11 14:27:44 -07:00
Rizumu Ayaka
46f4ce3890 feat: style of progress bar in various scenarios (#5492) 2025-09-11 14:07:44 -07:00
Comfy Org PR Bot
5df037dfc4 1.27.3 (#5497)
* [release] Increment version to 1.27.3

* fix(i18n): use import attributes for JSON to support Node/Playwright in i18n workflow

* Revert "fix(i18n): use import attributes for JSON to support Node/Playwright in i18n workflow"

This reverts commit b525242c32.

---------

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-09-11 13:44:02 -07:00
AustinMroz
3bc25b7aeb Add Asset Widget (#5475)
* [feat] carve out path to call asset browser in combo widget

* Add Asset Widget

* [feat] add fallback "Select model" label

---------

Co-authored-by: Arjan Singh <arjan@comfy.org>
2025-09-11 12:00:34 -07:00
Rizumu Ayaka
08fe2829d4 feat: node border and hover and selected style, and when error (#5491)
* feat: node border and hover and selected style, and when error

* fix test error
2025-09-11 03:04:16 -07:00
Jin Yi
e70ddea684 fix: Add dropdown size control to Select components and improve UI (#5290)
* feature: size adjust

* feature: design adjust

* fix: popover width, height added

* fix: li style override

* refactor: improve component readability and
  maintainability per PR feedback

  - Replace CardGridList component with
  createGridStyle utility function
  - Add runtime validation for grid column values
  - Remove !important usage in MultiSelect, use cn()
  function instead
  - Extract popover sizing logic into
  usePopoverSizing composable
  - Improve class string readability by splitting
  into logical groups
  - Use Tailwind size utilities (size-8, size-10)
  instead of separate width/height
  - Remove magic numbers in SearchBox, align with
  button sizes
  - Rename BaseWidgetLayout to BaseModalLayout for
  clarity
  - Enhance SearchBox click area to cover entire
  component
  - Refactor long class strings using cn() utility
  across components

* fix: BaseWidgetLayout => BaseModalLayout

* fix: CardGrid deleted

* fix: unused exported types

* Update test expectations [skip ci]

* chore: code review

* Update test expectations [skip ci]

* chore: restore screenshot

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-11 03:01:06 -07:00
Benjamin Lu
038f86fe84 fix(canvas): use vertical-align: top to eliminate baseline gap (#5484)
* fix(canvas): make graph canvas block-level to eliminate baseline gap

- Change <canvas id=graph-canvas> to display:block via Tailwind class
- Removes 1–5 px baseline offset between canvas and container
- Aligns canvas and TransformPane origins; fixes link/slot endpoint drift

No behavioral changes beyond layout origin alignment; no dependent CSS relies on inline/baseline.

* switch block to align-top

* Update test expectations [skip ci]

* Revert "Update test expectations [skip ci]"

This reverts commit ee0dfd4e0a.

* empty commit for ci

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-11 01:52:15 -07:00
snomiao
cb0dab6cdc [bugfix] Fix flaky test 'Does not report warning on undo/redo' (#5488)
Add additional wait after closing the dialog to ensure all async operations
complete before continuing with the test. This prevents race conditions
where the dialog might not be fully closed when the test proceeds.

The test was failing intermittently because closeDialog() waits for the
dialog to be hidden, but there may be additional async state updates that
need to complete after the dialog closes.

Fixes flaky test in dialog.spec.ts:33
2025-09-11 01:30:30 -07:00
AustinMroz
568be0c44c When toggling selected, align state (#5482)
Previously, when toggling the mode of multiple nodes, each node would
have its state individually toggled. Now it enables mode if any node is
not currently set to that mode and only disables if all already match.
2025-09-11 00:50:26 -07:00
Jin Yi
6ea021d595 feat: Auto-close LoadWorkflowWarning dialog when all missing nodes are installed (#5321)
* feat: Auto-close LoadWorkflowWarning dialog when all missing nodes are installed

- Add computed property to check if all missing nodes are installed
- Watch for completion and automatically close dialog with 500ms delay
- Show success toast notification when installation completes
- Add translation key for success message

This improves UX by automatically dismissing the warning dialog once the user has successfully installed all missing nodes through the manager.

* fix: settimeout to nexttick

* [auto-fix] Apply ESLint and Prettier fixes

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-10 23:19:04 -07:00
Alexander Brown
7245213ed6 Fix: In standard mode, don't stop when you hit a Vue node. (#5445)
* fix: Forward the scrolling events to the litegraph canvas.

* prior-art: Use the existing event forwarding logic from useCanvasInteractions (h/t Ben)

* fix: Get proper scaling from properties in the original event, fix browser zoom

* tests: Fix missing property on mock

* types: Cleanup type annotations in the test

* cleanup: Initialize the mocks in place.

* tests: extract createMockPointerEvent

* tests: extract createMockWheelEvent

* tests: extract createMockLGraphCanvas

* tests: Add additional assertion for stopPropagation

* tests: Comment pruning, test rename suggested by @arjansingh
2025-09-10 23:17:06 -07:00
Christian Byrne
b72e22f6be Add Centralized Vue Node Size/Pos Tracking (#5442)
* add dom element resize observer registry for vue node components

* Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts

Co-authored-by: AustinMroz <austin@comfy.org>

* refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates

* chore: make TransformState interface non-exported to satisfy knip pre-push

* Revert "chore: make TransformState interface non-exported to satisfy knip pre-push"

This reverts commit 110ecf31da.

* Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates"

This reverts commit 428752619c.

* [refactor] Improve resize tracking composable documentation and test utilities

- Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType)
- Add comprehensive docstring with examples to prevent DOM attribute confusion
- Extract mountLGraphNode test utility to eliminate repetitive mock setup
- Add technical implementation notes documenting optimization decisions

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

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

* remove typo comment

* convert to functional bounds collection

* remove inline import

* add interfaces for bounds mutations

* remove change log

* fix bounds collection when vue nodes turned off

* fix title offset on y

* move from resize observer to selection toolbox bounds

---------

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-10 22:38:49 -07:00
Jin Yi
5f045b335d [feat] Improve UX for disabled node packs in Manager dialog (#5478)
* [feat] Improve UX for disabled node packs in Manager dialog

- Hide "Update All" button when only disabled packs have updates
- Add tooltip on "Update All" hover to indicate disabled nodes won't be updated
- Disable version selector and show tooltip for disabled node packs
- Filter updates to only show enabled packs in the update queue
- Add visual indicators (opacity, cursor) for disabled pack cards
- Add comprehensive test coverage for new functionality

This improves the user experience by clearly indicating which packs
can be updated and preventing confusion about disabled packs.

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

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

* chore: missing nodes description added

* test: test code modified

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-10 22:35:16 -07:00
Arjan Singh
44e470488d [feat] carve out path to call asset browser in combo widget (#5464)
* [ci] ignore local browser tests files

this is where i have claude put its one off playwright scripts

* [feat] carve out path to call asset browser in combo widget

* [feat] use buttons on Model Loaders when Asset API setting is on
2025-09-10 22:26:07 -07:00
Alexander Brown
ca220440b2 Tailwind: Move customization into CSS (#5477)
* tailwind: Migrate out of the js/ts config part 1

* tailwind: Migrate custom variant and utility

* Update test expectations [skip ci]

* tailwind: Use relative colors for alpha variants

* fix: Use the new numbered color tokens

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-10 18:39:37 -07:00
Alexander Piskun
2e64c64ac7 add pricing for new ByteDance node (#5481) 2025-09-10 17:46:47 -07:00
snomiao
d561f315d3 fix: resolve flaky color picker test by adding missing await (#5467)
- Add missing await for async getProperty call in selectionToolbox test
- Add timestamp to test username generation to prevent duplicate user conflicts
- Test now passes consistently without race conditions

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-10 18:06:36 +09:00
snomiao
080334754c fix: update Claude PR Review workflow to use correct action parameters (#5473)
* fix: update Claude PR Review workflow to use correct action parameters

- Changed 'direct_prompt' to 'prompt' (correct parameter name)
- Moved max_turns and timeout to claude_args parameter
- Changed allowed_tools to additional_permissions parameter

The workflow was failing silently because it was using invalid input parameters
that the claude-code-action doesn't recognize.

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

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

* fix: pin claude-code-action to v1.0.6 to prevent future breakage

Using @main tag could cause unexpected breakage when the action updates.
Pinning to a specific version ensures stability.

* fix: apply review feedback - correct migration to v1.0 format

- Moved timeout-minutes to job level (not in claude_args)
- Changed additional_permissions to --allowedTools in claude_args
- Fixed tool specification format per migration guide

These changes follow the official v0.x to v1.0 migration guide exactly.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-09 23:25:36 -07:00
Christian Byrne
169d7404fe fix text prop area type error (#5471) 2025-09-09 22:24:04 -07:00
Christian Byrne
97d00ea47d [test] Add component tests for Vue node slots (#5461)
* add component tests for slots

* use `for of` for better error report

* add runtime type check to make assertions valid

* add runtime type check to make assertions valid
2025-09-09 18:48:51 -07:00
Christian Byrne
0a2260a666 Add design system color variables to tailwind config and use in Vue Nodes (#5430)
* use tailwind colors for

* add updated tokens with scales
2025-09-09 18:45:55 -07:00
Jin Yi
5b834acc86 feat(tailwind): add lucide icon support via iconify plugin (#5453) 2025-09-10 01:20:25 +00:00
Arjan Singh
7d4437c724 [fix] assets service review nits (#5444)
* [fix] assets service review nits

* [fix] lint
2025-09-09 17:00:23 -07:00
Benjamin Lu
9997053290 chore(lint): make ESLint concurrency configurable via pnpm config (#5448)
* chore(lint): make ESLint concurrency configurable via .env (default auto)

* Change default to be 4

* Change to config approach
2025-09-09 15:51:43 -07:00
Christian Byrne
43ab1c9b09 Add z-index management in Vue Nodes based on interaction recency (#5429)
* fix z-index on selection for vue nodes

* fix unused export

* refactor to DDD

* Use Tailwind utility for pointer events instead of inline style

Move pointer-events: auto from inline style to Tailwind class
pointer-events-auto as suggested in PR review.

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

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

* Rename defaultSource to layoutSource parameter

Rename parameter in useNodeZIndex options interface for better
clarity as suggested in PR review.

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

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

* Improve test mocking pattern with vi.mocked approach

Replace global mock object with per-test vi.mocked pattern
and proper Partial typing instead of as any, as suggested
in PR review.

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

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

* [auto-fix] Apply ESLint and Prettier fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-09 15:02:24 -07:00
Rizumu Ayaka
35b30a3ac6 fix: tailwind v4 utilities layer (#5451)
* fix: tailwind v4 utilities layer

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-09 00:20:24 -07:00
Rizumu Ayaka
6da2cf7b4d feat: vue based input number widget (#5435)
* feat: vue based input number widget

* fix: remove min and max
2025-09-08 23:34:07 -07:00
snomiao
6fbd692370 fix: Split PR deployment workflow for forked vs non-forked repos (#5425)
* [fix] Consolidate Playwright workflow jobs to fix missing deployment links

The issue in PR #5298 was caused by missing deployment-info artifact
creation. The deploy-reports job was deploying to Cloudflare but wasn't
creating the deployment-info-* artifacts that comment-tests-completed
job expected to download.

This change consolidates the deployment and commenting into a single job,
eliminating the artifact dependency and ensuring links are always available.

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

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

* refactor: Split PR deployment workflow for forked vs non-forked repos

- Extract deployment logic to reusable script (scripts/cicd/pr-playwright-deploy-and-comment.sh)
- Non-forked PRs: Use direct pull_request event in test-ui.yaml (faster)
- Forked PRs: Use workflow_run in pr-playwright-deploy.yaml (handles permissions)
- Add starting comment for both forked and non-forked PRs
- Make Cloudflare tokens optional for starting status comments

* refactor: Simplify PR deployment workflow and script

- Consolidate workflow into single job with clearer structure
- Reduce script from 200+ to ~140 lines
- Simplify deployment retry logic and comment generation
- Remove redundant checks and unnecessary complexity

* fix: Add debugging and wrangler installation to deployment script

- Add debug output to identify missing reports
- Install wrangler if not available
- Show deployment attempts and failures
- Log available reports before deployment

* chore: Trigger CI to test deployment workflow

* fix: Fix browser artifact name mismatch in deployment script

- Use dot notation (0.5x) for artifact names as Playwright creates them
- Convert to dash notation (0-5x) for Cloudflare project names
- Properly handle browser name display in comments

* refactor: Convert deployment script to POSIX sh for better compatibility

- Replace bash arrays with space-separated strings
- Use while loops instead of bash-specific for syntax
- Remove bash-specific string manipulation features
- Replace local variables (not required in functions)
- Ensure compatibility with standard /bin/sh

* fix: Fix deployment script output to properly capture URLs

- Redirect debug messages to stderr
- Only output URL to stdout for proper capture
- This fixes the missing deployment links in PR comments

* fix: Add input validation to prevent command injection

- Validate PR number is numeric only
- Sanitize branch name at script start
- Validate status parameter values
- Use pre-sanitized branch throughout script
- Addresses high-severity security issue from PR review

* fix: Add null checks and logging to workflow condition

- Add explicit null checks for head_repository and repository
- Add debug logging to help diagnose workflow trigger issues
- Prevents potential failures from undefined repository objects
- Addresses medium-severity issue from PR review

* fix: Pin wrangler to major version 4 with error handling

- Pin wrangler to major version 4 (^4.0.0) for stability
- Add error handling if wrangler installation fails
- Return 'failed' status if installation fails
- Addresses dependency management issue from PR review

* perf: Implement parallel deployments to reduce CI time

- Deploy all browser reports in parallel using background processes
- Use temporary directory to collect deployment results
- Wait for all deployments to complete before generating comment
- Maintains result order for consistent output
- Significantly reduces deployment time from sequential to parallel execution

* fix: Use specific comment ID for updates instead of edit-last

- Use GitHub API to find exact comment ID
- Update specific comment by ID to avoid editing wrong comment
- Prevents race conditions if user posts between finding and editing
- More reliable comment updates

* fix(workflows/test-ui.yaml): change condition to always run deploy job for pull requests to ensure deployment consistency

* fix(workflows/test-ui.yaml): change condition to always run deploy job for pull requests to ensure deployment consistency

* fix(pr-playwright-deploy-and-comment.sh): remove npx prefix from wrangler command for consistency and simplicity

* fix(pr-playwright-deploy-and-comment.sh): remove npx prefix from wrangler command for consistency and simplicity

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

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(pr-playwright-deploy-and-comment.sh): improve regex for URL extraction to include valid characters and ensure correct URL format

* chore(pr-playwright-deploy-and-comment.sh): move wrangler installation to the beginning of the script to avoid redundancy and improve efficiency

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-08 23:11:26 -07:00
Jin Yi
b800227783 feat: add dynamic icon support for NavItem components (#5285)
* feat: add dynamic icon support for NavItem components

- Created NavIcon component with switch-case based icon rendering
- Added iconName prop to NavItem and NavItemData interface
- Updated LeftSidePanel to pass icon names to nav items
- Added sample icons to SampleModelSelector navigation (download, tag, layers, grid)
- Uses i-lucide syntax without imports for better tree-shaking

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

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

* test: add Storybook stories for navigation components

- Add NavIcon.stories.ts with interactive icon selector and all icons gallery
- Add NavItem.stories.ts with text customization and interactive list examples
- Add LeftSidePanel.stories.ts with various navigation configurations
- Remove old Navigation.stories.ts (replaced with component-specific stories)
- Configure slot visibility and hide update:modelValue event in controls

* refactor: simplify NavIcon component and improve type definitions

* fix: add icon size specification for Lucide icons in Storybook

* feature: NavItem story modified

* fix: disable knip unresolved imports rule for virtual icon modules

Add unresolved: 'off' to knip configuration to ignore virtual module imports
from unplugin-icons (~icons/*). These are generated at build time and cannot
be resolved statically.

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

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

* chore: v-if condition added

* chore: knip ignoreUnresolved added based on knip issue PR

* refactor: navItem types added & deleting any type on storybook files

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 21:34:36 -07:00
Alexander Brown
fa9f5fbca6 Fix: Vue node/widget positioning and scroll issue (#5441)
* [feat] Refactor overlay compatibility into reusable composable

- Create useTransformCompatOverlayProps composable for centralized overlay prop management
- Update Select, MultiSelect, TreeSelect, and FileUpload components to use composable
- Provides appendTo='self' for transform inheritance in CSS-transformed parents
- Enables easy future additions of other transform compatibility props
- Fix duplicate v-bind attributes by combining props into single computed object

* fix: Keep the canvas container from being scrolled by children

* types: Align the appendTo type with primevue internals

* Update test expectations [skip ci]

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-09-08 16:05:33 -07:00
Benjamin Lu
aa7f8912a7 [refactor] Use getSlotPosition for Vue nodes in link rendering (#5400)
* Remove COMFY_VUE_NODE_DIMENSIONS

* [refactor] Use getSlotPosition for Vue nodes in link rendering

Replace direct node position calls with getSlotPosition utility when Vue nodes mode is enabled. This ensures consistent slot positioning across the canvas rendering system.

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

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

* Fix getSlotPosition readonly return value (#5433)

* Update accordingly to new type

* Fix canvas/screen conversion formulas in useTransformState (#5406)

* Fix conversion formulas

* update test expectations

* Remove unused type import

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-08 14:27:55 -07:00
Christian Byrne
76bfc9e678 [refactor] Centralize type assertions from yjs data (#5385)
* switch to schema interface and remove assertions at callsites

* [refactor] improve type safety and code organization - addresses @DrJKL's review feedback

- Remove unnecessary type assertions from REROUTE_DEFAULTS
- Use safer Omit<RerouteData, 'id'  < /dev/null |  'parentId'> pattern for defaults to prevent hardcoded ID bugs
- Extract asRerouteId and asLinkId utility functions to module scope as pure functions
- Update getRerouteField to handle partial defaults safely

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

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

* [fix] revert to clean defaults pattern - removes any type usage

Reverted the overcomplicated Omit pattern back to the simple, working approach.
The original pattern was cleaner and didn't introduce unnecessary complexity.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 12:55:31 -07:00
Benjamin Lu
0e44a4a354 Remove COMFY_VUE_NODE_DIMENSIONS constant (#5398)
* Remove COMFY_VUE_NODE_DIMENSIONS

* Update litegraph snapshot test
2025-09-08 12:53:42 -07:00
Alexander Brown
9a89869517 lint: Fix missing defaults for Props with Defaults (#5439) 2025-09-08 12:05:49 -07:00
Arjan Singh
551af4c0e0 [feat] Implement AssetService behind settings flag (#5404)
* [feat] add Comfy.Assets.UseAssetAPI to CORE_SETTINGS

* [feat] create AssetService

1. Add service for accessing new Asset API
2. Add fallback model paths logic so empty model directories appear for
   the user.
3. Copious tests for them all.

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

* [feat] switch between assets and file paths for model data

* [feat] ignore assets with "missing" tag

* [fix] formatting and style

* [fix] call assets API with the correct filters

* [feat] elminate unused modelPath code

* [fix] remove stray comment

* [fix] model manager api was not parsed correctly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 11:49:55 -07:00
filtered
76f21b9975 Fix bypass slot check only works for precise types (#5431) 2025-09-08 11:26:28 -07:00
Christian Byrne
9f42f3dfb9 fix unnecessary any type (#5428) 2025-09-08 04:06:43 -07:00
Dr.Lt.Data
d7a7ed6d93 refine locales/ko (#5432) 2025-09-08 04:03:28 -07:00
Christian Byrne
713ad134cf Implement selection state management in Vue Nodes (#5421)
* let canvas continue to own selection state management

* fix merge error

* refactor: use computed instead of watcher for selectedNodeIds

Replace watcher pattern with computed for better Vue idioms:
- More reactive and efficient
- Automatically recomputes when dependencies change
- Simpler, more declarative code

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

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

* fix: improve injection error handling for selectedNodeIds

Replace silent fallback with explicit error when SelectedNodeIds
is not provided:
- Fail fast instead of silently using empty Set
- Clear error message for debugging
- Prevents nodes appearing unselected due to missing provider

Addresses DrJKL's concern about injection default behavior.

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

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

* test: improve mocking patterns using vi.mockObject

Replace manual mock interfaces with vi.mockObject for better type safety:
- Use Vitest's built-in mocking utilities instead of manual interfaces
- Properly configure mock return values
- Remove unnecessary type assertions

Addresses DrJKL's feedback on test mocking patterns.

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

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

* test: extract repeated nodeData for clarity

Extract common test nodeData object to reduce duplication:
- Move repeated VueNodeData object to describe scope
- Replace 6 instances of identical nodeData declarations
- Maintain different nodeData for specific test cases

Addresses DrJKL's suggestion to extract repeated test data.

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

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

* add type safety to mocks

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 00:43:30 -07:00
Christian Byrne
1328da0fbd fix: Wait until graph configured to initialize Vue nodes (#5415)
* don't register vue nodes until after tasks flushed

* wait if configuring graph
2025-09-07 18:46:06 -07:00
Alexander Brown
6c7adf954a fix: Prevent pointer events on widgets from propagating to the containing node. (#5424) 2025-09-07 18:45:45 -07:00
Alexander Piskun
adcd81d08c update prices for Veo3 (#5418) 2025-09-07 12:09:35 -07:00
Alexander Brown
f6405e9125 Knip: More Pruning (#5374)
* knip: Don't ignore exports that are only used within a given file

* knip: More pruning after rebase

* knip: Vite plugin config fix

* knip: vitest plugin config

* knip: Playwright config, remove unnecessary ignores.

* knip: Simplify project file enumeration.

* knip: simplify the config file patterns ?(.optional_segment)

* knip: tailwind v4 fix

* knip: A little more, explain some of the deps.
Should be good for this PR.

* knip: remove unused disabling of classMembers.
It's opt-in, which we should probably do.

* knip: floating comments
We should probably delete _one_ of these parallell trees, right?

* knip: Add additional entrypoints

* knip: Restore UserData that's exposed via the types for now.

* knip: Add as an entry file even though knip says it's not necessary.

* knip: re-export functions used by nodes (h/t @christian-byrne)
2025-09-07 01:10:32 -07:00
Comfy Org PR Bot
5fa3b83918 [release] Increment version to 1.27.2 (#5417)
Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-07 00:40:33 -07:00
Jin Yi
e2de4b19fc Fix version detection for disabled packs (#5395)
* fix: normalize pack IDs to fix version detection for disabled packs

When a pack is disabled, ComfyUI-Manager returns it with a version suffix
(e.g., "ComfyUI-GGUF@1_1_4") while enabled packs don't have this suffix.
This inconsistency caused disabled packs to incorrectly show as having
updates available even when they were on the latest version.

Changes:
- Add normalizePackId utility to consistently remove version suffixes
- Apply normalization in refreshInstalledList and WebSocket updates
- Use the utility across conflict detection and node help modules
- Ensure pack version info is preserved in the object's ver field

This fixes the "Update Available" indicator incorrectly showing for
disabled packs that are already on the latest version.

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

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

* feature: test code added

* test: packUtils test code added

* test: address PR review feedback for test
  improvements

  - Remove unnecessary .not.toThrow() assertion
  in useManagerQueue test
  - Add clarifying comments for version
  normalization test logic
  - Replace 'as any' with vi.mocked() for better
  type safety

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 23:11:12 -07:00
Christian Byrne
0d3b15503f [test] Add component tests for some Vue Widget components (#5409)
* add component tests for vue widgets

* [refactor] improve widget test readability and type safety - addresses @DrJKL's review feedback

- Add mountComponent utility function for consistent test setup
- Add setInputValueAndTrigger helper to batch common test operations
- Replace type assertions with proper instanceof checks for type safety
- Remove duplicate test setup code to improve test readability
- Fix TypeScript errors in WidgetSlider tests

These changes address all review comments by making tests easier to read
and understand while ensuring proper type checking.

* [refactor] apply consistent test patterns to WidgetSelect.test.ts

- Add mountComponent utility function for consistent test setup
- Add setSelectValueAndEmit helper to batch select operations
- Remove repetitive mount boilerplate code throughout tests
- Maintain existing test coverage while improving readability

This ensures all widget component tests follow the same patterns
established in WidgetInputText and WidgetSlider tests.
2025-09-06 23:08:54 -07:00
Benjamin Lu
5c3b67bc6b Update pre-commit hook to use pnpm exec (#5412)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 22:03:19 -07:00
Benjamin Lu
d83dd4b984 Add pre-push hook to run knip (#5413)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 22:02:42 -07:00
Jin Yi
55882924fc [bugfix] Fix manager dialog warning banner close button visibility (#5397)
* feature: manager banner style fix

* fix: light-theme color

* fix: icon color modified for dark theme
2025-09-06 21:48:59 -07:00
AustinMroz
fc8d5621ac Implement subgraph publishing (#5139)
* Implement subgraph publishing

* Add missing null check

* Fix subgraph blueprint display in workflows tab

* Fix demotion of subgraph blueprints on reload

* Update locales [skip ci]

* Update blueprint def on save, cleanup

* Fix skipped tracking on subgraph publish

When a subgraph is first published, it previously was not added to the
subgraphCache. This would cause deletion to fail until a reload occurred.

* Fix failing vite tests

A couple of tests that were mocking classes broke SubgraphBlueprint
inheritance. Since they aren't testing anythign related to subgraph
blueprints, the subgraph store is mocked as well.

* Make blueprint breadcrumb badge clickable

* Add confirmation for overwrite on publish

* Simplify blueprint badge naming

* Swap to promise.allSettled when fetching subgraphs

* Navigate into subgraph on blueprint edit

* Revert mission of value in blueprint breadcrumb

This was causing the blueprint badge to always display

* Misc code quality fixes

* Set subgraphNode title on blueprint add.

When a subgraph blueprint is added to the graph, the title of the
subgraphNode is now set to be the title of the blueprint.

NOTE: The name of the subgraph node when a blueprint is edited is left
unchanged. This may cause minor user confusion.

* Add "Delete Blueprint" option to breadcrumb

When editing a blueprint, the options provided for the root graph of the
breadcrumb included a Delete Workflow option. This still functioned for
deleting the current blueprint when selected, but didn't make sense. It
has been updated to instead describe that it deletes the current
blueprint

* Extract subgraph load code as function

* Fix subgraphs appearing in library after refresh

Subgraph nodes were hidden from the node library and context menu by
setting skip_list to true. Unfortunately, this causes them to be
mistakenly be caught and registered as vue nodes when a refresh is
performed. This is fixed by adding a check for skip_list.

* Add delete button and confirmation for deletion

* Use more specific warning for blueprint deletion

* At success toast on subgraph publish

Will return later to potentially add a node library link to the toast

* Don't apply subgraph context menu to normal nodes

Subgraph blueprints have a right click -> delete option in the node
library.  This was incorrectly being dislplayed on non blueprint nodes.

* Remove hardcoded subgraphs path

Rather happy with this change. Rather than trying to introduce a
recursive import to pass a magic string, this solution is both
sufficient AND allows potential future extensions with less breakage.

* Fix nodeDef update on save

Wait to update the node def cache until after a blueprint has been
saved. Before, changes to links weren't actually being made visisble.

* Fix SaveAs with subgraph blueprints

* Remove ugly serialize/deserialize

Thought I had already tested this, and found that the mere existence of
proxies was causing issues, but simply adding a correct annotation is
sufficient now.

* Improve error specificity

* Framework for user defined blueprint descriptions

BlueprintDescription can be added to a workflows extra field to provide
more useful information about a blueprint's purpose

Actually hooking this up in a way that is user accessible is out of
scope for right now, but this will simplify future implementation.

* Cleanup breadcrumb dropdown options

Removes Dupliate for blueprints, adds a publish subgraph option.

The publish subgraph button currently routes through the save as logic.
Unforunately, this results in the prompt for name referencing workflows.
The cleanest way to resolve this is still being considered

* Move blueprint renaming into blueprint load

Blueprints should automatically set the name of the added node to the
filename when added. This mostly worked, but created uglier edgecases:
The subgraph itself wasn't renamed, and it would need to be
reimplemented to apply when editing a blueprint.

Instead, this is now applied when a subgraphBlueprint is first loaded.
This keeps all the logic routed through a single point

* Move saveAs prompt into workflow class

Ensures that the correct publish text is displayed when editing
blueprints without making an awful mess of imports

* Fix tests by making subgraphBlueprint internal

This has the added benefit of forcing better organization.

Reverts the useWorkflowThumbnail patch as it is no longer required.

* Add tests for subgraph blueprints

* Rewrite confirmation dialog

* Fix overwrite on publish new subgraph

1 is used as a placeholder size as -1 indicates the baking userFile is
temporary, not persisted, and therefore, not able to overwrite when
saved.

* When editing blueprint, tint background blue

* Fix blueprint tint at low LOD

* Set node source for blueprints to Blueprint

* Fix publish test

Making subgraph blueprints non temporary on publish made it so the
following load actually occurs. A mock has been added for this load.

* Fix multiple nits

* Further cleanup: error handling, and comments

* Fixing failing test cases

This also moves the bg tinting to a property of the workflow,
which makes things more extensible in the future.

* Fix temporary marking on publish.

The prior fix to allow overwrite of an existing blueprint on publish was
misguided. By marking a not-yet-loaded file as non-temporary, the load
performed prior to saving was actually fetching the file off disk and
discarding the existing changes. This additionally entirely prevented
publishing when a blueprint did not already exist with the current name.

To fix this, the blueprint is not marked as non-temporary until after
the load occurs. Note that this load is still required as it initializes
the change tracker state required for saving.

* Block unloading subgraph blueprints

Will need to be revisited if lazy loading is implemented, but this
requires solving some ugly sync/async issues.

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-06 21:27:04 -07:00
Christian Byrne
20d4fe709c [feat] Add ESLint rule for deprecated PrimeVue components (#5389)
* [feat] Add ESLint rule for deprecated PrimeVue components

Adds no-restricted-imports rule to catch usage of deprecated PrimeVue 4+ components and guide developers to use their replacements:
- Dropdown → Select
- OverlayPanel → Popover
- Calendar → DatePicker
- InputSwitch → ToggleSwitch
- Sidebar → Drawer

This prevents accidental usage of deprecated components and ensures consistency across the codebase.

* Add ESLint ignore for existing deprecated Dropdown usage

Adds TODO comment for future migration to Select component in SearchFilterDropdown.

* Update eslint.config.js

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

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-06 21:09:35 -07:00
Christian Byrne
2b8d354862 improve type narrowing syntax (#5380) 2025-09-06 20:27:44 -07:00
AustinMroz
db37693688 Remove accidental onMouseDown handler (#5405)
* Remove accidental onMouseDown handler

* Fix test snapshot

* Add test that onMouseDown isn't overwritten
2025-09-06 20:22:59 -07:00
Christian Byrne
c2eb4f03e9 fix: onNodeRemoved not called when loading new graph (and tearing down previous) (#5407)
* standardize graph cleanup

* test: fix useCoreCommands tests and add regression test

- Fix mocking to properly simulate app.clean() calling graph.clear()
- Add intelligent subgraph detection in mock to match real implementation
- Add regression test for Vue node cleanup bug to prevent future regressions
- Ensures app.clean() properly triggers onNodeRemoved events through graph.clear()

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

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

* fix unit tests

* move beforeLoadNewGraph to before graph is cleaned

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 19:35:57 -07:00
Arjan Singh
88809c7006 [ci] add concurrency=auto to eslint (#5408)
Update eslint dependencies to support [multithreaded execution](https://eslint.org/blog/2025/08/multithread-linting/).
2025-09-06 16:57:42 -07:00
Jin Yi
2eb5c2ab91 fix: feature flags and manager state handling (#5317)
* fix: Replace reactive feature flags with non-reactive approach

- Changed managerUIState from computed to getManagerUIState() function
- Ensures fresh computation on each call to avoid timing issues
- Updates all consumers to use the new function-based approach
- Fixes manager UI state determination issues

This change addresses the reactivity issues where feature flags were not
updating properly due to Vue's reactive system limitations with external
API values.

* fix: Add HelpCenter manager state handling and API version switching

- Fixed HelpCenter manager extension to check manager state
- Fixed 'Manager' command to respect manager state
- Added dynamic API prefix switching based on manager state
- Added debug logging for manager state determination

This ensures legacy manager uses /api/ prefix and new manager uses /api/v2/ prefix

* fix: Simplify manager state determination and fix API timing issues

- Remove unnecessary extension check from manager state store
- Use only feature flags (client and server) for state determination
- Default to NEW_UI when server flags not loaded (safer default)
- Fix ImportFailInfoBulk to not send empty requests
- Resolves initial 404 errors on installed API calls

* fix: Correct manager state determination for non-v4 servers

- Fix serverSupportsV4=false returning DISABLED instead of LEGACY_UI
- Server without v4 support should use legacy manager, not disable it
- Clarify condition for server v4 + client non-v4 case

* chore: Remove debug console.log statements

- Remove all debug logging from manager state store
- Remove logging from comfy manager service
- Clean up code for production

* test: Update manager state store tests to match new logic

- Update test expectations for server feature flags undefined case (returns NEW_UI)
- Update test expectations for server not supporting v4 case (returns LEGACY_UI)
- Tests now correctly reflect the actual behavior of the manager state logic

* fix: Remove dynamic API version handling in manager service

- Remove getApiBaseURL() function and axios interceptor
- Always use /api/v2/ for New Manager (hardcoded)
- Add isManagerServiceAvailable() to block service calls when not in NEW_UI state
- Simplify API handling as manager packages are now completely separated

* refactor: Add helper functions to managerStateStore for better code reuse

- Add isManagerEnabled(), isNewManagerUI(), isLegacyManagerUI() helpers
- Add shouldShowInstallButton(), shouldShowManagerButtons() for UI logic
- Update components to use helper functions where applicable
- Add comprehensive tests for new helper functions
- Centralize state checking logic to reduce duplication

* fix: Ensure SystemStats is loaded before conflict detection

- Move conflict detection from App.vue to GraphCanvas.vue
- Check manager state before running conflict detection
- Ensures SystemStats and feature flags are loaded first
- Prevents unnecessary API calls when manager is disabled

* docs: Clarify feature flag default behavior in manager state

- Add detailed comments explaining why NEW_UI is the default
- Clarify that undefined state is temporary during WebSocket connection
- Document graceful error handling when server doesn't support v2 API

* fix: Ensure consistent manager state handling for legacy commands

- Legacy commands now show error toast in NEW_UI mode
- Settings fallback for DISABLED mode
- Consistent error handling across all manager entry points
- Legacy commands only work in LEGACY_UI mode as expected

* refactor: centralize manager opening logic into managerStateStore

- Create openManager() function in managerStateStore to eliminate duplicate code
- Replace 8+ repeated switch statements across different files with single function
- Fix inconsistency where legacy command failure in LEGACY_UI mode incorrectly opened new manager
- Add support for legacy-only commands that should show error in NEW_UI mode
- Ensure all manager entry points behave consistently according to feature flags
- Clean up unused imports and fix ESLint errors

This addresses Christian's code review feedback about duplicate switch statements
and improves maintainability by providing a single source of truth for manager
opening logic.

* fix: use correct i18n import in managerStateStore

- Replace useI18n with direct t import from @/i18n
- Fixes issue where error messages showed as numbers (e.g. '26') instead of text
- Ensures toast messages display correctly in NEW_UI mode when legacy commands are invoked

* feature: initial tab fix

* test: Fix managerStateStore test failures by adding missing mocks

The test was failing because managerStateStore imports dialogService,
which imports ErrorDialogContent.vue that instantiates the app object.
This caused api.addEventListener errors in tests.

Added proper mocks for:
- dialogService
- commandStore
- toastStore

This prevents the problematic import chain and fixes the test failures.

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

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

* refactor: convert managerStateStore to composable

- Move managerStateStore from store to composable pattern
- All functions are non-reactive utilities that don't need state management
- Follows Pinia guideline: "If it's not reactive, it shouldn't be in a store"
- Update all import paths across 8 files
- Move and update test file accordingly

This change improves architecture consistency as other utility functions
in the codebase also use composables rather than stores when reactivity
is not required.

* refactor: use readonly computed properties instead of getter methods

- Convert all getter methods to readonly computed properties
- Follows Vue conventions for better performance through caching
- Change access pattern from function calls to .value properties
- Update all usages across 6 files
- Thanks to @DrJKL for the suggestion

This improves performance by caching computed values and aligns
with Vue's reactive system patterns.

* fix: check isManagerEnabled check to GraphCanvas.vue to avoid the side-effects of calling useConflictDetection which  include calling useComfyManagerStore

* chore: console.log to console.debug

* chore: useConflictDetection().initializeConflictDetection()

* test: add mockManagerDisabled option to disable manager in Playwright tests

- Add mockManagerDisabled parameter to ComfyPage.setup() (defaults to true)
- Override api.getServerFeature() to return false for manager feature flag
- Prevents manager initialization from interfering with subgraph tests
- Individual tests can still enable manager when needed by passing mockManagerDisabled: false

* chore: text modified

* fix: resolve CI/CD failures by fixing manager initialization timing

## Problem
GraphCanvas.vue was initializing conflict detection during component setup,
causing side effects in test environment where manager is disabled. This led
to 4 Playwright test failures in PR #5317.

## Root Cause
- GraphCanvas.vue called useConflictDetection() in setup phase
- This triggered store side effects even when manager was disabled
- systemStats wasn't ready when checking manager state

## Solution
1. Removed conflict detection initialization from GraphCanvas.vue entirely
2. Refactored systemStatsStore to use VueUse's useAsyncState pattern
3. Added isInitialized check in useManagerState to wait for systemStats
4. Updated useConflictDetection to check manager state internally

## Changes
- **GraphCanvas.vue**: Removed all conflict detection code
- **systemStatsStore**: Implemented useAsyncState for proper async handling
- **useManagerState**: Added isInitialized check before checking manager state
- **useConflictDetection**: Added internal manager state validation
- **App.vue**: Removed unnecessary fetchSystemStats calls
- **Tests**: Updated unit tests for new async behavior

## Test Results
All 4 previously failing Playwright tests now pass:
- featureFlags.spec.ts (feature flag handling)
- subgraph.spec.ts (breadcrumb updates, DOM cleanup)
- widget.spec.ts (image changes)

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

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

* chore: modified the note

* fix: test code modified

* fix: when manager is new manager ui, conflict detectetion should work

* fix: ensure fetch system stats before determine manager stats & when new ui & call legacy ui, open new manger dialog by default

* chore: unnecessary .value deleted & fetch name modified to refetch

* fix: ref type .value needed

* chore: vue use until pattern for waiting initializing

* fix: .value added

* fix: useManagerState test to properly mock reactive refs

The test was failing because it was mocking systemStats and isInitialized as plain values instead of reactive refs. The actual composable uses storeToRefs which returns refs with .value properties.

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

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

* fix: when system stats initialized, use until(systemStatsStore.isInitialized)

* fix: test

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 14:04:09 -07:00
Christian Byrne
57ebba1e72 fix: replace invalid return with exit 0 in backport workflow (#5401)
Fixes shell script error where 'return' was used outside of a function.
In shell scripts, 'exit 0' should be used to exit with success status.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 13:25:01 -07:00
Jin Yi
30d337c264 fix: packEnable button added hasConflict props (#5392) 2025-09-06 12:37:06 -07:00
Christian Byrne
b4b732ad8f [refactor] Extract Vue node entry point logic into focused composables (#5390)
* refactor: Extract Vue node entry point logic into focused composables

- Extract logic from GraphCanvas.vue (735→200 lines) into 3 composables:
  - useVueNodeLifecycle: Node manager initialization and cleanup
  - useViewportCulling: Viewport culling with transform sync
  - useNodeEventHandlers: Node selection, collapse, title handlers

- Remove type assertions by using comfyApp.canvas instead of canvasStore.canvas
- Eliminate getter anti-pattern with proper Vue reactive refs
- Fix all TypeScript compatibility issues without workarounds
- Maintain proper separation of concerns and Vue-idiomatic patterns

* style: Remove extra comments from return statement

* [auto-fix] Apply ESLint and Prettier fixes

* style: Remove conversational comments

- Remove temporary comments that only made sense in refactoring context
- Clean up comment wording to be more permanent/professional
- Keep meaningful comments about code behavior and architecture

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-06 04:26:23 -07:00
Benjamin Lu
8445605777 fix: correct Vue ref auto-unwrapping in slot components (#5378)
Fixed the misuse of exposed template refs in InputSlot and OutputSlot components.
When Vue exposes a Ref via defineExpose, it auto-unwraps it on the parent
component instance. The previous code was incorrectly double-unwrapping by
calling .value on an already unwrapped HTMLElement.

Changes:
- Updated type to ComponentPublicInstance with unwrapped HTMLElement
- Replaced watch with watchEffect for better timing handling
- Removed incorrect .value access on auto-unwrapped ref

This ensures slot elements are properly registered with useDomSlotRegistration,
fixing slot position tracking and hit-testing in the layout system.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 03:37:39 -07:00
pythongosssss
0df7a53ead Rework theme menu (#5161)
* Change theme "button" to sub menu of all themes

* Add test for theme menu

* Prevent separator being added before View

* Refactor test

* Update locales [skip ci]

* Fix has-text vs text-is change breaking other tests

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-09-06 03:09:58 -07:00
Christian Byrne
0854194aa1 [refactor] Refactor rendering-related files to DDD organization (#5388)
* refactor rendering-related files to DDD organization

* add to git ignore ignore revs
2025-09-06 02:47:37 -07:00
AustinMroz
f719ed941f When converting single group to subgraph, also convert children (#5217)
* On conversion of single group, convert children

If convert to subgraph is called on a selection consisting of a single
group, the groups children are also converted to subgraph.

* Update locales [skip ci]

* Create new set to avoid mutating passed argument

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-06 02:15:54 -07:00
Christian Byrne
b00952e6b3 [lint] Enforce custom dark-theme: prefix instead of standard dark: prefix (#5382)
- Add vue/no-restricted-class ESLint rule to catch dark: prefix usage
- Fix existing violation in FormImageUpload.vue (dark: -> dark-theme:)
- Ensures consistent usage of project's custom Tailwind variant
- Violations will fail builds and be caught during development

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 01:57:46 -07:00
Christian Byrne
42128fa39f [fix] Fix step prop scaling issue on numeric Vue widgets (#5386)
* use step2 -> step bind on slider widget

* fix: Use step2 instead of legacy step property in WidgetSlider

The WidgetSlider was using the legacy `step` property (10x input spec value)
instead of `step2` (correct input spec value). This caused input spec step
values to appear 10x larger than intended.

- Use `widget.options.step2` (correct input spec value)
- Remove fallback to `widget.options.step` (legacy 10x value)
- Both properties coexist, so step2 should always be preferred

Fixes input spec step values not being respected in Vue node sliders.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-06 01:57:08 -07:00
Benjamin Lu
9122a4758d Implement proper invalidation on switch (#5383) 2025-09-06 01:49:01 -07:00
Alexander Piskun
e65ebdb4c2 add prices for StabilityAudio API nodes (#5387) 2025-09-06 01:47:46 -07:00
Benjamin Lu
a9715a8a09 [cleanup] Remove debug logging from slot layout updates (#5384) 2025-09-06 01:47:21 -07:00
Christian Byrne
718ec42deb [refactor] Consolidate reroute handlers (#5379)
- Replace handleRerouteAdd + handleRerouteUpdate with single handleRerouteUpsert
- Both operations performed identical logic (full layout replacement)
- Remove redundant parameter passing (rerouteIdStr + rerouteId)
- Remove 'export type { LayoutStore } from types' pattern that obscures dependencies
2025-09-06 00:36:51 -07:00
Christian Byrne
104760b2c2 remove unused spatial index manager (#5381) 2025-09-06 00:36:37 -07:00
Christian Byrne
bd299b8210 [devex] Update hotfix release command to work with new automated backporting process (#5271)
* Update hotfix release command for modern automated backport workflow

- Add Step 0 to check automated backport status first
- Emphasize this command is fallback when automation fails
- Add critical draft release publishing step (uncheck 'Set as latest')
- Add ComfyUI requirements.txt PR creation with exact template
- Update workflow context for modern automated backports

* Restructure hotfix release command for modern workflow

- Add clear process overview and context at top
- Step 1: Try automated backports first (via labels)
- Fallback to manual cherry-picking only if automation fails
- Add critical draft release publishing step (uncheck 'Set as latest')
- Add ComfyUI requirements.txt PR creation with exact template
- Remove time estimates and reorganize for clarity
- Update step numbering and cross-references

* Enable backport automation for already-merged PRs

- Add 'labeled' trigger to backport workflow
- Allow backport automation when needs-backport label is added to merged PRs
- Supports hotfix workflow where labels are added retroactively
- Maintains existing behavior for PRs merged with labels already present

* Prevent duplicate backport triggers with idempotency check

- Add check for existing backport PRs before starting backport process
- Skip backport work if PRs already exist for the same PR number
- Prevents double execution when both 'labeled' and 'closed' events trigger
- Maintains workflow reliability and avoids duplicate backport PRs

* Add smart backport detection to hotfix command

- Check for existing automated backport PRs and their status
- Path A: Skip to version bump if backports already merged
- Path B: Guide user to merge pending backport PRs first
- Path C: Fall back to manual cherry-picking if no/failed automation
- Add clear workflow path documentation for different scenarios

* Add automated fork handling for ComfyUI requirements.txt PRs

- Check if fork exists, create if needed
- Clone fork to local ComfyUI-fork directory
- Create branch, update requirements.txt with sed
- Create PR from fork using gh CLI with exact template format
- Handle both new and existing fork scenarios
- Keep fork directory for future updates

* [style] improve backport workflow logging and structure

- Change ::notice to ::warning for existing backports per @DrJKL's feedback
- Refactor conditional to use guard clause pattern per @DrJKL's suggestion
- Improves readability and follows conventional shell scripting patterns

Addresses @DrJKL's review comments in PR #5271
2025-09-05 21:11:46 -07:00
Christian Byrne
687580e155 [docs] Fix ADR numbering and add missing entry to README (#5365)
- Rename 0004-crdt-based-layout-system.md to 0003-crdt-based-layout-system.md
- Update title from "4. Centralized..." to "3. Centralized..."
- Add ADR 0003 entry to docs/adr/README.md index table

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

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-05 20:38:05 -07:00
Christian Byrne
0fdc584f34 refactor: Remove branch protection API calls from release workflow (#5376)
Branch protection for core/X.XX branches will now be managed through
GitHub repository rulesets with wildcard pattern matching, providing
more consistent and centralized protection rule management.
2025-09-05 20:00:07 -07:00
Simula_r
0bc53eb2ad Fix/toolbox node detection (#5361)
* refactor: dont need will change on animations

* fix: by disabling parent pointer events and forcing on child element

* fix: color picker watch with immediate option

* Update test expectations [skip ci]

---------

Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-09-05 18:34:11 -07:00
AustinMroz
a69b241f07 Fix incorrect id resolution in image preview store (#5357) 2025-09-05 15:04:34 -05:00
AustinMroz
1ae67dd9d1 Fix double click primitive widgets with subgraphs (#5372)
Double clicking the input slot of a node creates and connects a
primitive widget. However, this code would always add the created
primitive to the root graph.
2025-09-05 10:35:55 -07:00
Benjamin Lu
091a3b3136 chore: remove MCP config and related dev entries (#5362) 2025-09-05 09:49:13 -07:00
Benjamin Lu
fac27723fb [refactor] Use public nodes getter instead of private _nodes property (#5369) 2025-09-05 09:11:39 -07:00
Comfy Org PR Bot
f0c607e940 [release] Increment version to 1.27.1 (#5366)
Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-04 23:37:53 -07:00
336 changed files with 9970 additions and 5836 deletions

View File

@@ -1,30 +1,85 @@
# Create Hotfix Release
This command guides you through creating a patch/hotfix release for ComfyUI Frontend with comprehensive safety checks and human confirmations at each step.
This command creates patch/hotfix releases for ComfyUI Frontend by backporting fixes to stable core branches. It handles both automated backports (preferred) and manual cherry-picking (fallback).
**Process Overview:**
1. **Check automated backports first** (via labels)
2. **Skip to version bump** if backports already merged
3. **Manual cherry-picking** if automation failed
4. **Create patch release** with version bump
5. **Publish GitHub release** (manually uncheck "latest")
6. **Update ComfyUI requirements.txt** via PR
<task>
Create a hotfix release by cherry-picking commits or PR commits from main to a core branch: $ARGUMENTS
Create a hotfix release by backporting commits/PRs from main to a core branch: $ARGUMENTS
Expected format: Comma-separated list of commits or PR numbers
Examples:
- `abc123,def456,ghi789` (commits)
- `#1234,#5678` (PRs)
- `abc123,#1234,def456` (mixed)
- `#1234,#5678` (PRs - preferred)
- `abc123,def456` (commit hashes)
- `#1234,abc123` (mixed)
If no arguments provided, the command will help identify the correct core branch and guide you through selecting commits/PRs.
If no arguments provided, the command will guide you through identifying commits/PRs to backport.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean working tree
- You understand the commits/PRs you're cherry-picking
- Push access to repository
- GitHub CLI (`gh`) authenticated
- Clean working tree
- Understanding of what fixes need backporting
## Hotfix Release Process
### Step 1: Identify Target Core Branch
### Step 1: Try Automated Backports First
**Check if automated backports were attempted:**
1. **For each PR, check existing backport labels:**
```bash
gh pr view #1234 --json labels | jq -r '.labels[].name'
```
2. **If no backport labels exist, add them now:**
```bash
# Add backport labels (this triggers automated backports)
gh pr edit #1234 --add-label "needs-backport"
gh pr edit #1234 --add-label "1.24" # Replace with target version
```
3. **Check for existing backport PRs:**
```bash
# Check for backport PRs created by automation
PR_NUMBER=${ARGUMENTS%%,*} # Extract first PR number from arguments
PR_NUMBER=${PR_NUMBER#\#} # Remove # prefix
gh pr list --search "backport-${PR_NUMBER}-to" --json number,title,state,baseRefName
```
4. **Handle existing backport scenarios:**
**Scenario A: Automated backports already merged**
```bash
# Check if backport PRs were merged to core branches
gh pr list --search "backport-${PR_NUMBER}-to" --state merged
```
- If backport PRs are merged → Skip to Step 10 (Version Bump)
- **CONFIRMATION**: Automated backports completed, proceeding to version bump?
**Scenario B: Automated backport PRs exist but not merged**
```bash
# Show open backport PRs that need merging
gh pr list --search "backport-${PR_NUMBER}-to" --state open
```
- **ACTION REQUIRED**: Merge the existing backport PRs first
- Use: `gh pr merge [PR_NUMBER] --merge` for each backport PR
- After merging, return to this command and skip to Step 10 (Version Bump)
- **CONFIRMATION**: Have you merged all backport PRs? Ready to proceed to version bump?
**Scenario C: No automated backports or they failed**
- Continue to Step 2 for manual cherry-picking
- **CONFIRMATION**: Proceeding with manual cherry-picking because automation failed?
### Step 2: Identify Target Core Branch
1. Fetch the current ComfyUI requirements.txt from master branch:
```bash
@@ -36,7 +91,7 @@ Before starting, ensure:
5. Verify the core branch exists: `git ls-remote origin refs/heads/core/*`
6. **CONFIRMATION REQUIRED**: Is `core/X.Y` the correct target branch?
### Step 2: Parse and Validate Arguments
### Step 3: Parse and Validate Arguments
1. Parse the comma-separated list of commits/PRs
2. For each item:
@@ -49,7 +104,7 @@ Before starting, ensure:
- **CONFIRMATION REQUIRED**: Use merge commit or cherry-pick individual commits?
4. Validate all commit hashes exist in the repository
### Step 3: Analyze Target Changes
### Step 4: Analyze Target Changes
1. For each commit/PR to cherry-pick:
- Display commit hash, author, date
@@ -60,7 +115,7 @@ Before starting, ensure:
2. Identify potential conflicts by checking changed files
3. **CONFIRMATION REQUIRED**: Proceed with these commits?
### Step 4: Create Hotfix Branch
### Step 5: Create Hotfix Branch
1. Checkout the core branch (e.g., `core/1.23`)
2. Pull latest changes: `git pull origin core/X.Y`
@@ -69,7 +124,7 @@ Before starting, ensure:
- Example: `hotfix/1.23.4-20241120`
5. **CONFIRMATION REQUIRED**: Created branch correctly?
### Step 5: Cherry-pick Changes
### Step 6: Cherry-pick Changes
For each commit:
1. Attempt cherry-pick: `git cherry-pick <commit>`
@@ -83,7 +138,7 @@ For each commit:
- Run validation: `pnpm typecheck && pnpm lint`
4. **CONFIRMATION REQUIRED**: Cherry-pick successful and valid?
### Step 6: Create PR to Core Branch
### Step 7: Create PR to Core Branch
1. Push the hotfix branch: `git push origin hotfix/<version>-<timestamp>`
2. Create PR using gh CLI:
@@ -100,7 +155,7 @@ For each commit:
- Impact assessment
5. **CONFIRMATION REQUIRED**: PR created correctly?
### Step 7: Wait for Tests
### Step 8: Wait for Tests
1. Monitor PR checks: `gh pr checks`
2. Display test results as they complete
@@ -111,7 +166,7 @@ For each commit:
4. Wait for all required checks to pass
5. **CONFIRMATION REQUIRED**: All tests passing?
### Step 8: Merge Hotfix PR
### Step 9: Merge Hotfix PR
1. Verify all checks have passed
2. Check for required approvals
@@ -119,7 +174,7 @@ For each commit:
4. Delete the hotfix branch
5. **CONFIRMATION REQUIRED**: PR merged successfully?
### Step 9: Create Version Bump
### Step 10: Create Version Bump
1. Checkout the core branch: `git checkout core/X.Y`
2. Pull latest changes: `git pull origin core/X.Y`
@@ -131,7 +186,7 @@ For each commit:
7. Commit: `git commit -m "[release] Bump version to 1.23.5"`
8. **CONFIRMATION REQUIRED**: Version bump correct?
### Step 10: Create Release PR
### Step 11: Create Release PR
1. Push release branch: `git push origin release/1.23.5`
2. Create PR with Release label:
@@ -184,7 +239,7 @@ For each commit:
```
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process
### Step 12: Monitor Release Process
1. Wait for PR checks to pass
2. **FINAL CONFIRMATION**: Ready to trigger release by merging?
@@ -199,7 +254,102 @@ For each commit:
- PyPI upload
- pnpm types publication
### Step 12: Post-Release Verification
### Step 13: Manually Publish Draft Release
**CRITICAL**: The release workflow creates a DRAFT release. You must manually publish it:
1. **Go to GitHub Releases:** https://github.com/Comfy-Org/ComfyUI_frontend/releases
2. **Find the DRAFT release** (e.g., "v1.23.5 Draft")
3. **Click "Edit release"**
4. **UNCHECK "Set as the latest release"** ⚠️ **CRITICAL**
- This prevents the hotfix from showing as "latest"
- Main branch should always be "latest release"
5. **Click "Publish release"**
6. **CONFIRMATION REQUIRED**: Draft release published with "latest" unchecked?
### Step 14: Create ComfyUI Requirements.txt Update PR
**IMPORTANT**: Create PR to update ComfyUI's requirements.txt via fork:
1. **Setup fork (if needed):**
```bash
# Check if fork already exists
if gh repo view ComfyUI --json owner | jq -r '.owner.login' | grep -q "$(gh api user --jq .login)"; then
echo "Fork already exists"
else
# Fork the ComfyUI repository
gh repo fork comfyanonymous/ComfyUI --clone=false
echo "Created fork of ComfyUI"
fi
```
2. **Clone fork and create branch:**
```bash
# Clone your fork (or use existing clone)
GITHUB_USER=$(gh api user --jq .login)
if [ ! -d "ComfyUI-fork" ]; then
gh repo clone ${GITHUB_USER}/ComfyUI ComfyUI-fork
fi
cd ComfyUI-fork
git checkout master
git pull origin master
# Create update branch
BRANCH_NAME="update-frontend-${NEW_VERSION}"
git checkout -b ${BRANCH_NAME}
```
3. **Update requirements.txt:**
```bash
# Update the version in requirements.txt
sed -i "s/comfyui-frontend-package==[0-9].*$/comfyui-frontend-package==${NEW_VERSION}/" requirements.txt
# Verify the change
grep "comfyui-frontend-package" requirements.txt
# Commit the change
git add requirements.txt
git commit -m "Bump frontend to ${NEW_VERSION}"
git push origin ${BRANCH_NAME}
```
4. **Create PR from fork:**
```bash
# Create PR using gh CLI from fork
gh pr create \
--repo comfyanonymous/ComfyUI \
--title "Bump frontend to ${NEW_VERSION}" \
--body "$(cat <<EOF
Bump frontend to ${NEW_VERSION}
\`\`\`
python main.py --front-end-version Comfy-Org/ComfyUI_frontend@${NEW_VERSION}
\`\`\`
- Diff: [Comfy-Org/ComfyUI_frontend: v${OLD_VERSION}...v${NEW_VERSION}](https://github.com/Comfy-Org/ComfyUI_frontend/compare/v${OLD_VERSION}...v${NEW_VERSION})
- PyPI Package: https://pypi.org/project/comfyui-frontend-package/${NEW_VERSION}/
- npm Types: https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/${NEW_VERSION}
## Changes
- Fix: [Brief description of hotfixes included]
EOF
)"
```
5. **Clean up:**
```bash
# Return to original directory
cd ..
# Keep fork directory for future updates
echo "Fork directory 'ComfyUI-fork' kept for future use"
```
6. **CONFIRMATION REQUIRED**: ComfyUI requirements.txt PR created from fork?
### Step 15: Post-Release Verification
1. Verify GitHub release:
```bash
@@ -213,12 +363,14 @@ For each commit:
```bash
pnpm view @comfyorg/comfyui-frontend-types@1.23.5
```
4. Generate release summary with:
4. Monitor ComfyUI requirements.txt PR for approval/merge
5. Generate release summary with:
- Version released
- Commits included
- Issues fixed
- Distribution status
5. **CONFIRMATION REQUIRED**: Release completed successfully?
- ComfyUI integration status
6. **CONFIRMATION REQUIRED**: Hotfix release fully completed?
## Safety Checks
@@ -240,19 +392,28 @@ If something goes wrong:
## Important Notes
- **Always try automated backports first** - This command is for when automation fails
- Core branch version will be behind main - this is expected
- The "Release" label triggers the PyPI/npm publication
- **CRITICAL**: Always uncheck "Set as latest release" for hotfix releases
- **Must create ComfyUI requirements.txt PR** - Hotfix isn't complete without it
- PR numbers must include the `#` prefix
- Mixed commits/PRs are supported but review carefully
- Always wait for full test suite before proceeding
## Expected Timeline
## Modern Workflow Context
- Step 1-3: ~10 minutes (analysis)
- Steps 4-6: ~15-30 minutes (cherry-picking)
- Step 7: ~10-20 minutes (tests)
- Steps 8-10: ~10 minutes (version bump)
- Step 11-12: ~15-20 minutes (release)
- Total: ~60-90 minutes
**Primary Backport Method:** Automated via `needs-backport` + `X.YY` labels
**This Command Usage:**
- Smart path detection - skip to version bump if backports already merged
- Fallback to manual cherry-picking only when automation fails/has conflicts
**Complete Hotfix:** Includes GitHub release publishing + ComfyUI requirements.txt integration
This process ensures a safe, verified hotfix release with multiple confirmation points and clear tracking of what changes are being released.
## Workflow Paths
- **Path A:** Backports already merged → Skip to Step 10 (Version Bump)
- **Path B:** Backport PRs need merging → Merge them → Skip to Step 10 (Version Bump)
- **Path C:** No/failed backports → Manual cherry-picking (Steps 2-9) → Version Bump (Step 10)
This process ensures a complete hotfix release with proper GitHub publishing, ComfyUI integration, and multiple safety checkpoints.

View File

@@ -4,3 +4,6 @@
# npm run format on litegraph merge (10,672 insertions, 7,327 deletions across 129 files)
c53f197de2a3e0fa66b16dedc65c131235c1c4b6
# Reorganize renderer components into domain-driven folder structure
c8a83a9caede7bdb5f8598c5492b07d08c339d49

View File

@@ -2,7 +2,7 @@ name: Auto Backport
on:
pull_request_target:
types: [closed]
types: [closed, labeled]
branches: [main]
jobs:
@@ -25,7 +25,27 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Check if backports already exist
id: check-existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Check for existing backport PRs for this PR number
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
if [ -z "$EXISTING_BACKPORTS" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "Found existing backport PRs:"
echo "$EXISTING_BACKPORTS"
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Backport PRs already exist for PR #${PR_NUMBER}, skipping to avoid duplicates"
- name: Extract version labels
if: steps.check-existing.outputs.skip != 'true'
id: versions
run: |
# Extract version labels (e.g., "1.24", "1.22")
@@ -52,6 +72,7 @@ jobs:
echo "Found version labels: ${VERSIONS}"
- name: Backport commits
if: steps.check-existing.outputs.skip != 'true'
id: backport
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -109,14 +130,13 @@ jobs:
fi
- name: Create PR for each successful backport
if: steps.backport.outputs.success
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
@@ -141,7 +161,7 @@ jobs:
done
- name: Comment on failures
if: failure() && steps.backport.outputs.failed
if: steps.check-existing.outputs.skip != 'true' && failure() && steps.backport.outputs.failed
env:
GH_TOKEN: ${{ github.token }}
run: |

View File

@@ -47,6 +47,7 @@ jobs:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -69,19 +70,17 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1.0.6
with:
label_trigger: "claude-review"
direct_prompt: |
prompt: |
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
DO NOT create a summary comment.
Each issue must be posted as a separate inline comment on the specific line of code.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: 256
timeout_minutes: 30
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
claude_args: "--max-turns 256 --allowedTools 'Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch'"
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -128,45 +128,6 @@ jobs:
echo "- Critical security patches"
echo "- Documentation updates"
- name: Create branch protection rules
if: steps.check_version.outputs.is_minor_bump == 'true' && env.branch_exists != 'true'
env:
GITHUB_TOKEN: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="${{ steps.check_version.outputs.branch_name }}"
# Create branch protection using GitHub API
echo "Setting up branch protection for $BRANCH_NAME..."
RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/branches/$BRANCH_NAME/protection" \
-d '{
"required_status_checks": {
"strict": true,
"contexts": ["lint-and-format", "test", "playwright-tests"]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}')
HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [[ "$HTTP_CODE" -eq 200 ]] || [[ "$HTTP_CODE" -eq 201 ]]; then
echo "✅ Branch protection successfully applied"
else
echo "⚠️ Failed to apply branch protection (HTTP $HTTP_CODE)"
echo "Response: $BODY"
# Don't fail the workflow, just warn
fi
- name: Post summary
if: steps.check_version.outputs.is_minor_bump == 'true'

View File

@@ -1,4 +1,4 @@
name: PR Playwright Deploy and Comment
name: PR Playwright Deploy (Forks)
on:
workflow_run:
@@ -9,272 +9,84 @@ env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-reports:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed'
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
permissions:
pull-requests: write
actions: read
strategy:
fail-fast: false
matrix:
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Get PR info
id: pr-info
- name: Log workflow trigger info
run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v4
- name: Get PR Number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return { number: null, sanitized_branch: null };
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
const pr = pullRequests[0];
const branchName = context.payload.workflow_run.head_branch;
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
return {
number: pr.number,
sanitized_branch: sanitizedBranch
};
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Set project name
if: fromJSON(steps.pr-info.outputs.result).number != null
id: project-name
- name: Handle Test Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
else
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
fi
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
- name: Download playwright report
if: fromJSON(steps.pr-info.outputs.result).number != null
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: playwright-report-${{ matrix.browser }}
path: playwright-report
- name: Install Wrangler
if: fromJSON(steps.pr-info.outputs.result).number != null
run: npm install -g wrangler
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
if: fromJSON(steps.pr-info.outputs.result).number != null
id: cloudflare-deploy
continue-on-error: true
run: |
# Retry logic for wrangler deploy (3 attempts)
RETRY_COUNT=0
MAX_RETRIES=3
SUCCESS=false
pattern: playwright-report-*
path: reports
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
SUCCESS=true
echo "Deployment successful on attempt $RETRY_COUNT"
else
echo "Deployment failed on attempt $RETRY_COUNT"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "Retrying in 10 seconds..."
sleep 10
fi
fi
done
if [ $SUCCESS = false ]; then
echo "All deployment attempts failed"
exit 1
fi
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
comment-tests-starting:
runs-on: ubuntu-latest
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for start
if: steps.pr.outputs.result != 'null'
id: comment-body-start
GITHUB_TOKEN: ${{ github.token }}
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
echo "<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Tests are starting...** " >> comment.md
echo "" >> comment.md
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 🚀 Running Tests" >> comment.md
echo "- 🧪 **chromium**: Running tests..." >> comment.md
echo "- 🧪 **chromium-0.5x**: Running tests..." >> comment.md
echo "- 🧪 **chromium-2x**: Running tests..." >> comment.md
echo "- 🧪 **mobile-chrome**: Running tests..." >> comment.md
echo "" >> comment.md
echo "---" >> comment.md
echo "⏱️ Please wait while tests are running across all browsers..." >> comment.md
- name: Comment PR - Tests Started
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
comment-tests-completed:
runs-on: ubuntu-latest
needs: deploy-reports
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' && always()
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Download all deployment info
if: steps.pr.outputs.result != 'null'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: deployment-info-*
merge-multiple: true
path: deployment-info
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
- name: Generate comment body for completion
if: steps.pr.outputs.result != 'null'
id: comment-body-completed
run: |
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
echo "## 🎭 Playwright Test Results" >> comment.md
echo "" >> comment.md
# Check if all tests passed
ALL_PASSED=true
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
if [ "$exit_code" != "0" ]; then
ALL_PASSED=false
break
fi
fi
done
if [ "$ALL_PASSED" = "true" ]; then
echo "✅ **All tests passed across all browsers!**" >> comment.md
else
echo "❌ **Some tests failed!**" >> comment.md
fi
echo "" >> comment.md
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
echo "" >> comment.md
echo "### 📊 Test Reports by Browser" >> comment.md
for file in deployment-info/*.txt; do
if [ -f "$file" ]; then
browser=$(basename "$file" .txt)
info=$(cat "$file")
exit_code=$(echo "$info" | cut -d'|' -f2)
url=$(echo "$info" | cut -d'|' -f3)
# Validate URLs before using them in comments
sanitized_url=$(echo "$url" | grep -E '^https://[a-z0-9.-]+\.pages\.dev(/.*)?$' || echo "INVALID_URL")
if [ "$sanitized_url" = "INVALID_URL" ]; then
echo "Invalid deployment URL detected: $url"
url="#" # Use safe fallback
fi
if [ "$exit_code" = "0" ]; then
status="✅"
else
status="❌"
fi
echo "- $status **$browser**: [View Report]($url)" >> comment.md
fi
done
echo "" >> comment.md
echo "---" >> comment.md
if [ "$ALL_PASSED" = "true" ]; then
echo "🎉 Your tests are passing across all browsers!" >> comment.md
else
echo "⚠️ Please check the test reports for details on failures." >> comment.md
fi
- name: Comment PR - Tests Complete
if: steps.pr.outputs.result != 'null'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body-path: comment.md
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -284,3 +284,65 @@ jobs:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
retention-days: 30
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all playwright reports
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
- name: Deploy reports and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
run: |
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

1
.gitignore vendored
View File

@@ -51,6 +51,7 @@ tests-ui/workflows/examples
/blob-report/
/playwright/.cache/
browser_tests/**/*-win32.png
browser-tests/local/
.env

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
npx lint-staged
npx tsx scripts/check-unused-i18n-keys.ts
pnpm exec lint-staged
pnpm exec tsx scripts/check-unused-i18n-keys.ts

5
.husky/pre-push Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Run Knip with cache via package script
pnpm knip

View File

@@ -1,12 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
}
}
}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
ignore-workspace-root-check=true

View File

@@ -57,9 +57,8 @@
/* Override Storybook's problematic & selector styles */
/* Reset only the specific properties that Storybook injects */
#storybook-root li+li,
#storybook-docs li+li {
margin: inherit;
padding: inherit;
li+li {
margin: 0;
padding: revert-layer;
}
</style>

View File

@@ -10,7 +10,7 @@ import type { Position } from './types'
* - {@link Mouse.move}
* - {@link Mouse.up}
*/
export interface DragOptions {
interface DragOptions {
button?: 'left' | 'right' | 'middle'
clickCount?: number
steps?: number

View File

@@ -453,6 +453,32 @@ export class ComfyPage {
await workflowsTab.close()
}
/**
* Attach a screenshot to the test report.
* By default, screenshots are only taken in non-CI environments.
* @param name - Name for the screenshot attachment
* @param options - Optional configuration
* @param options.runInCI - Whether to take screenshot in CI (default: false)
* @param options.fullPage - Whether to capture full page (default: false)
*/
async attachScreenshot(
name: string,
options: { runInCI?: boolean; fullPage?: boolean } = {}
) {
const { runInCI = false, fullPage = false } = options
// Skip in CI unless explicitly requested
if (process.env.CI && !runInCI) {
return
}
const testInfo = comfyPageFixture.info()
await testInfo.attach(name, {
body: await this.page.screenshot({ fullPage }),
contentType: 'image/png'
})
}
async resetView() {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()

View File

@@ -1,7 +1,13 @@
import { Locator, Page } from '@playwright/test'
import { Locator, Page, expect } from '@playwright/test'
export class Topbar {
constructor(public readonly page: Page) {}
private readonly menuLocator: Locator
private readonly menuTrigger: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfyui-logo-wrapper')
}
async getTabNames(): Promise<string[]> {
return await this.page
@@ -15,10 +21,33 @@ export class Topbar {
.innerText()
}
getMenuItem(itemLabel: string): Locator {
/**
* Get a menu item by its label, optionally within a specific parent container
*/
getMenuItem(itemLabel: string, parent?: Locator): Locator {
if (parent) {
return parent.locator(`.p-tieredmenu-item:has-text("${itemLabel}")`)
}
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
/**
* Get the visible submenu (last visible submenu in case of nested menus)
*/
getVisibleSubmenu(): Locator {
return this.page.locator('.p-tieredmenu-submenu:visible').last()
}
/**
* Check if a menu item has an active checkmark
*/
async isMenuItemActive(menuItem: Locator): Promise<boolean> {
const checkmark = menuItem.locator('.pi-check')
const classes = await checkmark.getAttribute('class')
return classes ? !classes.includes('invisible') : false
}
getWorkflowTab(tabName: string): Locator {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
@@ -66,10 +95,50 @@ export class Topbar {
async openTopbarMenu() {
await this.page.waitForTimeout(1000)
await this.page.locator('.comfyui-logo-wrapper').click()
const menu = this.page.locator('.comfy-command-menu')
await menu.waitFor({ state: 'visible' })
return menu
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
}
/**
* Close the topbar menu by clicking outside
*/
async closeTopbarMenu() {
await this.page.locator('body').click({ position: { x: 10, y: 10 } })
await expect(this.menuLocator).not.toBeVisible()
}
/**
* Navigate to a submenu by hovering over a menu item
*/
async openSubmenu(menuItemLabel: string): Promise<Locator> {
const menuItem = this.getMenuItem(menuItemLabel)
await menuItem.hover()
const submenu = this.getVisibleSubmenu()
await submenu.waitFor({ state: 'visible' })
return submenu
}
/**
* Get theme menu items and interact with theme switching
*/
async getThemeMenuItems() {
const themeSubmenu = await this.openSubmenu('Theme')
return {
submenu: themeSubmenu,
darkTheme: this.getMenuItem('Dark (Default)', themeSubmenu),
lightTheme: this.getMenuItem('Light', themeSubmenu)
}
}
/**
* Switch to a specific theme
*/
async switchTheme(theme: 'dark' | 'light') {
const { darkTheme, lightTheme } = await this.getThemeMenuItems()
const themeItem = theme === 'dark' ? darkTheme : lightTheme
const themeLabel = themeItem.locator('.p-menubar-item-label')
await themeLabel.click()
}
async triggerTopbarCommand(path: string[]) {
@@ -79,9 +148,7 @@ export class Topbar {
const menu = await this.openTopbarMenu()
const tabName = path[0]
const topLevelMenuItem = this.page.locator(
`.p-menubar-item-label:text-is("${tabName}")`
)
const topLevelMenuItem = this.getMenuItem(tabName)
const topLevelMenu = menu
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })

View File

@@ -134,7 +134,7 @@ export class SubgraphSlotReference {
}
}
export class NodeSlotReference {
class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
@@ -201,7 +201,7 @@ export class NodeSlotReference {
}
}
export class NodeWidgetReference {
class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference

View File

@@ -36,6 +36,10 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing/missing_nodes')
await comfyPage.closeDialog()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(100)
// Make a change to the graph
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -178,6 +178,72 @@ test.describe('Menu', () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
comfyPage
}) => {
const { topbar } = comfyPage.menu
// Take initial screenshot with default theme
await comfyPage.attachScreenshot('theme-initial')
// Open the topbar menu
const menu = await topbar.openTopbarMenu()
await expect(menu).toBeVisible()
// Get theme menu items
const {
submenu: themeSubmenu,
darkTheme: darkThemeItem,
lightTheme: lightThemeItem
} = await topbar.getThemeMenuItems()
await expect(darkThemeItem).toBeVisible()
await expect(lightThemeItem).toBeVisible()
// Switch to Light theme
await topbar.switchTheme('light')
// Verify menu stays open and Light theme shows as active
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
// Check that Light theme is active
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
// Screenshot with light theme active
await comfyPage.attachScreenshot('theme-menu-light-active')
// Verify ColorPalette setting is set to "light"
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
// Re-open menu and get theme items again
await topbar.openTopbarMenu()
const themeItems2 = await topbar.getThemeMenuItems()
// Switch back to Dark theme
await topbar.switchTheme('dark')
// Verify menu stays open and Dark theme shows as active
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
// Check that Dark theme is active and Light theme is not
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
// Screenshot with dark theme active
await comfyPage.attachScreenshot('theme-menu-dark-active')
// Verify ColorPalette setting is set to "dark"
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('dark')
// Close menu
await topbar.closeTopbarMenu()
})
})
// Only test 'Top' to reduce test time.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -149,7 +149,7 @@ test.describe('Selection Toolbox', () => {
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(selectedNode.getProperty('color')).not.toBeNull()
expect(await selectedNode.getProperty('color')).not.toBeNull()
})
test('color picker shows current color of selected nodes', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -1,4 +1,4 @@
# 4. Centralized Layout Management with CRDT
# 3. Centralized Layout Management with CRDT
Date: 2025-08-27

View File

@@ -12,6 +12,7 @@ An Architecture Decision Record captures an important architectural decision mad
|-----|-------|--------|------|
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
## Creating a New ADR

View File

@@ -62,6 +62,44 @@ export default [
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/one-component-per-file': 'off', // TODO: fix
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'primevue/calendar',
message:
'Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from "primevue/datepicker"'
},
{
name: 'primevue/dropdown',
message:
'Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from "primevue/select"'
},
{
name: 'primevue/inputswitch',
message:
'InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from "primevue/toggleswitch"'
},
{
name: 'primevue/overlaypanel',
message:
'OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from "primevue/popover"'
},
{
name: 'primevue/sidebar',
message:
'Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from "primevue/drawer"'
}
]
}
],
// i18n rules
'@intlify/vue-i18n/no-raw-text': [
'error',

View File

@@ -2,85 +2,56 @@ import type { KnipConfig } from 'knip'
const config: KnipConfig = {
entry: [
'build/**/*.ts',
'scripts/**/*.{js,ts}',
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'vite.electron.config.mts',
'vite.types.config.mts'
],
project: [
'browser_tests/**/*.{js,ts}',
'build/**/*.{js,ts,vue}',
'scripts/**/*.{js,ts}',
'src/**/*.{js,ts,vue}',
'tests-ui/**/*.{js,ts,vue}',
'*.{js,ts,mts}'
'src/scripts/ui/menu/index.ts',
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}'],
ignoreBinaries: ['only-allow', 'openapi-typescript'],
ignoreDependencies: [
// Weird importmap things
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons',
'@iconify/json',
'tailwindcss',
'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying
// Dev
'@executeautomation/playwright-mcp-server',
'@trivago/prettier-plugin-sort-imports'
],
ignore: [
// Generated files
'dist/**',
'types/**',
'node_modules/**',
// Config files that might not show direct usage
'.husky/**',
// Temporary or cache files
'.vite/**',
'coverage/**',
// i18n config
'.i18nrc.cjs',
// Vitest litegraph config
'vitest.litegraph.config.ts',
// Test setup files
'browser_tests/globalSetup.ts',
'browser_tests/globalTeardown.ts',
'browser_tests/utils/**',
// Scripts
'scripts/**',
// Vite config files
'vite.electron.config.mts',
'vite.types.config.mts',
// Auto generated manager types
'src/types/generatedManagerTypes.ts',
// Design system components (may not be used immediately)
'src/components/button/IconGroup.vue',
'src/components/button/MoreButton.vue',
'src/components/button/TextButton.vue',
'src/components/card/CardTitle.vue',
'src/components/card/CardDescription.vue',
'src/components/input/SingleSelect.vue',
'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Generated file: openapi
'src/types/comfyRegistryTypes.ts'
'src/scripts/ui/components/splitButton.ts'
],
ignoreExportsUsedInFile: true,
// Vue-specific configuration
vue: true,
tailwind: true,
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
classMembers: 'off'
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[
...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)
].join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},
vitest: {
config: ['vitest?(.*).config.ts'],
entry: [
'**/*.{bench,test,test-d,spec}.?(c|m)[jt]s?(x)',
'**/__mocks__/**/*.[jt]s?(x)'
]
},
playwright: {
config: ['playwright?(.*).config.ts'],
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
],
// Include dependencies analysis
includeEntryExports: true
]
}
export default config

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.0",
"version": "1.27.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -39,7 +39,7 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.6",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
@@ -63,7 +63,7 @@
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.12.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-storybook": "^9.1.1",
@@ -77,15 +77,15 @@
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"uuid": "^11.1.0",
@@ -141,6 +141,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"reka-ui": "^2.5.0",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",

1522
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
#!/bin/bash
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- PLAYWRIGHT_TEST_STATUS -->"
# Use dot notation for artifact names (as Playwright creates them)
BROWSERS="chromium chromium-2x chromium-0.5x mobile-chrome"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Deploy a single browser report, WARN: ensure inputs are sanitized before calling this function
deploy_report() {
dir="$1"
browser="$2"
branch="$3"
[ ! -d "$dir" ] && echo "failed" && return
# Project name with dots converted to dashes for Cloudflare
sanitized_browser=$(echo "$browser" | sed 's/\./-/g')
project="comfyui-playwright-${sanitized_browser}"
echo "Deploying $browser to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch and project are already sanitized, use them directly
# Branch was sanitized at script start, project uses sanitized_browser
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
# Create new comment
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Test Results
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
else
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for reports in: $(pwd)/reports"
echo "Available reports:"
ls -la reports/ 2>/dev/null || echo "Reports directory not found"
# Deploy all reports in parallel and collect URLs
temp_dir=$(mktemp -d)
pids=""
i=0
# Start parallel deployments
for browser in $BROWSERS; do
if [ -d "reports/playwright-report-$browser" ]; then
echo "Found report for $browser, deploying in parallel..."
(
url=$(deploy_report "reports/playwright-report-$browser" "$browser" "$cloudflare_branch")
echo "$url" > "$temp_dir/$i.url"
echo "Deployment result for $browser: $url"
) &
pids="$pids $!"
else
echo "Report not found for $browser at reports/playwright-report-$browser"
echo "failed" > "$temp_dir/$i.url"
fi
i=$((i + 1))
done
# Wait for all deployments to complete
for pid in $pids; do
wait $pid
done
# Collect URLs in order
urls=""
i=0
for browser in $BROWSERS; do
if [ -f "$temp_dir/$i.url" ]; then
url=$(cat "$temp_dir/$i.url")
else
url="failed"
fi
if [ -z "$urls" ]; then
urls="$url"
else
urls="$urls $url"
fi
i=$((i + 1))
done
# Clean up temp directory
rm -rf "$temp_dir"
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Test Results
✅ **Tests completed successfully!**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 📊 Test Reports by Browser"
# Add browser results
i=0
for browser in $BROWSERS; do
# Get URL at position i
url=$(echo "$urls" | cut -d' ' -f$((i + 1)))
if [ "$url" != "failed" ] && [ -n "$url" ]; then
comment="$comment
- ✅ **${browser}**: [View Report](${url})"
else
comment="$comment
- ❌ **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
comment="$comment
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -15,10 +15,10 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from './composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()

View File

@@ -2,71 +2,12 @@
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@plugin "tailwindcss-primeui";
@plugin 'tailwindcss-primeui';
@config '../../../tailwind.config.ts';
@layer tailwind-utilities {
/* Set default values to prevent some styles from not working properly. */
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(66 153 225 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
@tailwind components;
@tailwind utilities;
}
:root {
--fg-color: #000;
--bg-color: #fff;
@@ -107,6 +48,99 @@
}
}
@theme {
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Palette Colors */
--color-charcoal-100: #171718;
--color-charcoal-200: #202121;
--color-charcoal-300: #262729;
--color-charcoal-400: #2d2e32;
--color-charcoal-500: #313235;
--color-charcoal-600: #3c3d42;
--color-charcoal-700: #494a50;
--color-charcoal-800: #55565e;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
--color-ivory-100: #fdfbfa;
--color-ivory-200: #faf9f5;
--color-ivory-300: #f0eee6;
--color-gray-100: #f3f3f3;
--color-gray-200: #e9e9e9;
--color-gray-300: #e1e1e1;
--color-gray-400: #d9d9d9;
--color-gray-500: #c5c5c5;
--color-gray-600: #b4b4b4;
--color-gray-700: #a0a0a0;
--color-gray-800: #8a8a8a;
--color-sand-100: #e1ded5;
--color-sand-200: #d6cfc2;
--color-sand-300: #888682;
--color-slate-100: #9c9eab;
--color-slate-200: #9fa2bd;
--color-slate-300: #5b5e7d;
--color-brand-yellow: #f0ff41;
--color-brand-blue: #172dd7;
--color-blue-100: #0b8ce9;
--color-blue-200: #31b9f4;
--color-success-100: #00cd72;
--color-success-200: #47e469;
--color-warning-100: #fd9903;
--color-warning-200: #fcbf64;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-error: #962a2a;
--color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);
/* Special Colors (temporary) */
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@theme inline {
--color-node-component-surface: var(--color-charcoal-300);
--color-node-component-surface-highlight: var(--color-slate-100);
--color-node-component-surface-hovered: var(--color-charcoal-500);
--color-node-component-surface-selected: var(--color-charcoal-700);
--color-node-stroke: var(--color-stone-100);
}
@custom-variant dark-theme {
.dark-theme & {
@slot;
}
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
/* Everthing below here to be cleaned up over time. */
body {
width: 100vw;
height: 100vh;
@@ -849,7 +883,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation{
.comfy-preview-3d-animation {
display: flex;
flex-direction: column;
background: transparent;
@@ -862,7 +896,7 @@ audio.comfy-audio.empty-audio-widget {
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas{
.comfy-load-3d-viewer canvas {
display: flex;
width: 100% !important;
height: 100% !important;
@@ -939,7 +973,9 @@ audio.comfy-audio.empty-audio-widget {
.lg-node .lg-slot,
.lg-node .lg-widget {
transition: opacity 0.1s ease, font-size 0.1s ease;
transition:
opacity 0.1s ease,
font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
@@ -971,4 +1007,3 @@ audio.comfy-audio.empty-audio-widget {
/* Use solid colors only */
background-image: none !important;
}

View File

@@ -37,7 +37,7 @@ const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0

View File

@@ -40,6 +40,7 @@ import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbIt
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -52,6 +53,9 @@ const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
@@ -89,6 +93,7 @@ const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -16,6 +16,7 @@
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
@@ -48,6 +49,7 @@
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -121,7 +123,7 @@ const menuItems = computed<MenuItem[]>(() => {
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot
visible: isRoot && !props.item.isBlueprint
},
{
separator: true,
@@ -153,12 +155,26 @@ const menuItems = computed<MenuItem[]>(() => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Bell, Download, Heart, Settings, Trophy, X } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
@@ -33,13 +32,13 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconButton, Trophy },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
`
}),
@@ -51,13 +50,13 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconButton, Settings },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
`
}),
@@ -69,13 +68,13 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconButton, X },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
`
}),
@@ -87,13 +86,13 @@ export const Transparent: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconButton, Bell },
components: { IconButton },
setup() {
return { args }
},
template: `
<IconButton v-bind="args">
<Bell :size="12" />
<i class="icon-[lucide--bell] size-3" />
</IconButton>
`
}),
@@ -105,42 +104,42 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: { IconButton, Trophy, Settings, X, Bell, Heart, Download },
components: { IconButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconButton type="primary" size="sm" @click="() => {}">
<Trophy :size="12" />
<i class="icon-[lucide--trophy] size-3" />
</IconButton>
<IconButton type="primary" size="md" @click="() => {}">
<Trophy :size="16" />
<i class="icon-[lucide--trophy] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="secondary" size="sm" @click="() => {}">
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="transparent" size="sm" @click="() => {}">
<X :size="12" />
<i class="icon-[lucide--x] size-3" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
<div class="flex gap-2 items-center">
<IconButton type="primary" size="md" @click="() => {}">
<Bell :size="16" />
<i class="icon-[lucide--bell] size-4" />
</IconButton>
<IconButton type="secondary" size="md" @click="() => {}">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton type="transparent" size="md" @click="() => {}">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
</div>
</div>

View File

@@ -1,5 +1,11 @@
<template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot></slot>
</Button>
</template>
@@ -15,11 +21,16 @@ import {
getButtonTypeClasses,
getIconButtonSizeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void
}
defineOptions({
inheritAttrs: false
})
const {
size = 'md',
type = 'secondary',
@@ -36,8 +47,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ExternalLink, Heart } from 'lucide-vue-next'
import IconButton from './IconButton.vue'
import IconGroup from './IconGroup.vue'
@@ -17,17 +16,17 @@ type Story = StoryObj<typeof IconGroup>
export const Basic: Story = {
render: () => ({
components: { IconGroup, IconButton, Download, ExternalLink, Heart },
components: { IconGroup, IconButton },
template: `
<IconGroup>
<IconButton @click="console.log('Hello World!!')">
<Heart :size="16" />
<i class="icon-[lucide--heart] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</IconButton>
<IconButton @click="console.log('Hello World!!')">
<ExternalLink :size="16" />
<i class="icon-[lucide--external-link] size-4" />
</IconButton>
</IconGroup>
`

View File

@@ -1,7 +1,17 @@
<template>
<div
class="flex justify-center items-center shrink-0 outline-hidden border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
>
<div :class="iconGroupClasses">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'cursor-pointer'
)
</script>

View File

@@ -1,14 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
ChevronLeft,
ChevronRight,
Download,
Package,
Save,
Settings,
Trash2,
X
} from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
@@ -49,14 +39,14 @@ type Story = StoryObj<typeof meta>
export const Primary: Story = {
render: (args) => ({
components: { IconTextButton, Package },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Package :size="16" />
<i class="icon-[lucide--package] size-4" />
</template>
</IconTextButton>
`
@@ -70,14 +60,14 @@ export const Primary: Story = {
export const Secondary: Story = {
render: (args) => ({
components: { IconTextButton, Settings },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
`
@@ -91,14 +81,14 @@ export const Secondary: Story = {
export const Transparent: Story = {
render: (args) => ({
components: { IconTextButton, X },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<X :size="16" />
<i class="icon-[lucide--x] size-4" />
</template>
</IconTextButton>
`
@@ -112,14 +102,14 @@ export const Transparent: Story = {
export const WithIconRight: Story = {
render: (args) => ({
components: { IconTextButton, ChevronRight },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
`
@@ -134,14 +124,14 @@ export const WithIconRight: Story = {
export const Small: Story = {
render: (args) => ({
components: { IconTextButton, Save },
components: { IconTextButton },
setup() {
return { args }
},
template: `
<IconTextButton v-bind="args">
<template #icon>
<Save :size="12" />
<i class="icon-[lucide--save] size-3" />
</template>
</IconTextButton>
`
@@ -156,66 +146,60 @@ export const Small: Story = {
export const AllVariants: Story = {
render: () => ({
components: {
IconTextButton,
Download,
Settings,
Trash2,
ChevronRight,
ChevronLeft,
Save
IconTextButton
},
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<IconTextButton label="Download" type="primary" size="sm" @click="() => {}">
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Download" type="primary" size="md" @click="() => {}">
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Settings" type="secondary" size="sm" @click="() => {}">
<template #icon>
<Settings :size="12" />
<i class="icon-[lucide--settings] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Settings" type="secondary" size="md" @click="() => {}">
<template #icon>
<Settings :size="16" />
<i class="icon-[lucide--settings] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Delete" type="transparent" size="sm" @click="() => {}">
<template #icon>
<Trash2 :size="12" />
<i class="icon-[lucide--trash-2] size-3" />
</template>
</IconTextButton>
<IconTextButton label="Delete" type="transparent" size="md" @click="() => {}">
<template #icon>
<Trash2 :size="16" />
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
<div class="flex gap-2 items-center">
<IconTextButton label="Next" type="primary" size="md" iconPosition="right" @click="() => {}">
<template #icon>
<ChevronRight :size="16" />
<i class="icon-[lucide--chevron-right] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Previous" type="secondary" size="md" @click="() => {}">
<template #icon>
<ChevronLeft :size="16" />
<i class="icon-[lucide--chevron-left] size-4" />
</template>
</IconTextButton>
<IconTextButton label="Save File" type="primary" size="md" @click="() => {}">
<template #icon>
<Save :size="16" />
<i class="icon-[lucide--save] size-4" />
</template>
</IconTextButton>
</div>

View File

@@ -1,5 +1,11 @@
<template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
@@ -17,6 +23,11 @@ import {
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
@@ -42,8 +53,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Download, ScrollText } from 'lucide-vue-next'
import IconTextButton from './IconTextButton.vue'
import MoreButton from './MoreButton.vue'
@@ -18,7 +17,7 @@ type Story = StoryObj<typeof MoreButton>
export const Basic: Story = {
render: () => ({
components: { MoreButton, IconTextButton, Download, ScrollText },
components: { MoreButton, IconTextButton },
template: `
<div style="height: 200px; display: flex; align-items: center; justify-content: center;">
<MoreButton>
@@ -29,7 +28,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<Download :size="16" />
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
@@ -39,7 +38,7 @@ export const Basic: Story = {
@click="() => { close() }"
>
<template #icon>
<ScrollText :size="16" />
<i class="icon-[lucide--scroll-text] size-4" />
</template>
</IconTextButton>
</template>

View File

@@ -14,7 +14,7 @@
unstyled
:pt="pt"
>
<div class="flex flex-col gap-1 p-2 min-w-40">
<div class="flex flex-col gap-2 p-2 min-w-40">
<slot :close="hide" />
</div>
</Popover>
@@ -25,6 +25,8 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue'
const popover = ref<InstanceType<typeof Popover>>()
@@ -39,13 +41,16 @@ const hide = () => {
const pt = computed(() => ({
root: {
class: 'absolute z-50'
class: cn('absolute z-50')
},
content: {
class: [
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
]
class: cn(
'mt-2 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-zinc-200 dark-theme:border-zinc-700'
)
}
}))
</script>

View File

@@ -1,5 +1,11 @@
<template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<span>{{ label }}</span>
</Button>
</template>
@@ -15,12 +21,17 @@ import {
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
interface TextButtonProps extends BaseButtonProps {
label: string
onClick: () => void
}
defineOptions({
inheritAttrs: false
})
const {
size = 'md',
type = 'primary',
@@ -38,8 +49,6 @@ const buttonStyle = computed(() => {
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)
return [baseClasses, sizeClasses, typeClasses, className]
.filter(Boolean)
.join(' ')
return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

View File

@@ -1,13 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Folder,
Heart,
Info,
MoreVertical,
Star,
Upload
} from 'lucide-vue-next'
import { ref } from 'vue'
import IconButton from '../button/IconButton.vue'
@@ -58,14 +49,6 @@ const meta: Meta<CardStoryArgs> = {
options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio'
},
maxWidth: {
control: { type: 'range', min: 200, max: 600, step: 10 },
description: 'Maximum width in pixels'
},
minWidth: {
control: { type: 'range', min: 150, max: 400, step: 10 },
description: 'Minimum width in pixels'
},
topRatio: {
control: 'select',
options: ['square', 'landscape'],
@@ -149,14 +132,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download,
Star,
Upload,
MoreVertical
SquareChip
},
setup() {
const favorited = ref(false)
@@ -171,11 +147,10 @@ const createCardTemplate = (args: CardStoryArgs) => ({
}
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div class="min-h-screen">
<CardContainer
:ratio="args.containerRatio"
:max-width="args.maxWidth"
:min-width="args.minWidth"
class="max-w-[320px] mx-auto"
>
<template #top>
<CardTop :ratio="args.topRatio">
@@ -202,14 +177,14 @@ const createCardTemplate = (args: CardStoryArgs) => ({
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info clicked')"
>
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
<IconButton
class="!bg-white/90"
:class="favorited ? '!text-red-500' : '!text-neutral-900'"
@click="toggleFavorite"
>
<Heart :size="16" :fill="favorited ? 'currentColor' : 'none'" />
<i class="icon-[lucide--heart] size-4" :class="favorited ? 'fill-current' : ''" />
</IconButton>
</template>
@@ -222,7 +197,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -230,7 +205,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template #bottom>
<CardBottom class="p-3">
<CardBottom class="p-3 bg-neutral-100">
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
@@ -244,8 +219,6 @@ export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 300,
minWidth: 200,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -271,8 +244,6 @@ export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 400,
minWidth: 250,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
@@ -298,8 +269,6 @@ export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 280,
minWidth: 180,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -325,8 +294,6 @@ export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
maxWidth: 350,
minWidth: 220,
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -351,8 +318,6 @@ export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
maxWidth: 300,
minWidth: 200,
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
@@ -377,8 +342,6 @@ export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
maxWidth: 320,
minWidth: 240,
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -392,274 +355,10 @@ export const FullFeaturedCard: Story = {
backgroundColor: '#ef4444',
showImage: false,
imageUrl: '',
tags: ['Bundle', 'Premium', 'SDXL'],
tags: ['Bundle', 'SDXL'],
showFileSize: true,
fileSize: '5.4 GB',
showFileType: true,
fileType: 'pack'
}
}
export const GridOfCards: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
IconButton,
SquareChip,
Info,
Folder,
Heart,
Download
},
setup() {
const cards = ref([
{
id: 1,
title: 'Realistic Vision',
description: 'Photorealistic model for portraits',
color: 'from-blue-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['SD 1.5'],
size: '2.1 GB'
},
{
id: 2,
title: 'DreamShaper XL',
description: 'Artistic style model with enhanced details',
color: 'from-purple-400 to-pink-600',
ratio: 'portrait' as const,
tags: ['SDXL'],
size: '6.5 GB'
},
{
id: 3,
title: 'Anime LoRA',
description: 'Character style LoRA',
color: 'from-green-400 to-teal-600',
ratio: 'portrait' as const,
tags: ['LoRA'],
size: '144 MB'
},
{
id: 4,
title: 'VAE Model',
description: 'Enhanced color VAE',
color: 'from-orange-400 to-red-600',
ratio: 'portrait' as const,
tags: ['VAE'],
size: '335 MB'
},
{
id: 5,
title: 'Workflow Bundle',
description: 'Complete workflow setup',
color: 'from-indigo-400 to-blue-600',
ratio: 'portrait' as const,
tags: ['Workflow'],
size: '45 KB'
},
{
id: 6,
title: 'Embedding Pack',
description: 'Negative embeddings collection',
color: 'from-yellow-400 to-orange-600',
ratio: 'portrait' as const,
tags: ['Embedding'],
size: '2.3 MB'
}
])
return { cards }
},
template: `
<div class="p-4 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Model Gallery</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<CardContainer
v-for="card in cards"
:key="card.id"
:ratio="card.ratio"
:max-width="300"
:min-width="180"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-gray-600"
:class="card.color"
></div>
</template>
<template #top-right>
<IconButton
class="!bg-white/90 !text-neutral-900"
@click="() => console.log('Info:', card.title)"
>
<Info :size="16" />
</IconButton>
</template>
<template #bottom-right>
<SquareChip
v-for="tag in card.tags"
:key="tag"
:label="tag"
>
<template v-if="tag === 'LoRA'" #icon>
<Folder :size="12" />
</template>
</SquareChip>
<SquareChip :label="card.size" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
`
})
}
export const ResponsiveGrid: Story = {
render: () => ({
components: {
CardContainer,
CardTop,
CardBottom,
CardTitle,
CardDescription,
SquareChip
},
setup() {
const generateCards = (
count: number,
ratio: 'square' | 'portrait' | 'tallPortrait'
) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
title: `Model ${i + 1}`,
description: `Description for model ${i + 1}`,
ratio,
color: `hsl(${(i * 60) % 360}, 70%, 60%)`
}))
}
const squareCards = ref(generateCards(4, 'square'))
const portraitCards = ref(generateCards(6, 'portrait'))
const tallCards = ref(generateCards(5, 'tallPortrait'))
return {
squareCards,
portraitCards,
tallCards
}
},
template: `
<div class="p-4 space-y-8 min-h-screen bg-zinc-50 dark-theme:bg-zinc-900">
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Square Cards (1:1)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<CardContainer
v-for="card in squareCards"
:key="card.id"
:ratio="card.ratio"
:max-width="400"
:min-width="200"
>
<template #top>
<CardTop ratio="landscape">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Portrait Cards (2:3)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<CardContainer
v-for="card in portraitCards"
:key="card.id"
:ratio="card.ratio"
:max-width="280"
:min-width="160"
>
<template #top>
<CardTop ratio="square">
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-2">
<CardTitle>{{ card.title }}</CardTitle>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-neutral-900 dark-theme:text-neutral-100">Tall Portrait Cards (2:4)</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<CardContainer
v-for="card in tallCards"
:key="card.id"
:ratio="card.ratio"
:max-width="260"
:min-width="150"
>
<template #top>
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full"
:style="{ backgroundColor: card.color }"
></div>
</template>
<template #bottom-right>
<SquareChip :label="'#' + card.id" />
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="p-3">
<CardTitle>{{ card.title }}</CardTitle>
<CardDescription>{{ card.description }}</CardDescription>
</CardBottom>
</template>
</CardContainer>
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div :class="containerClasses" :style="containerStyle">
<div :class="containerClasses">
<slot name="top"></slot>
<slot name="bottom"></slot>
</div>
@@ -8,13 +8,7 @@
<script setup lang="ts">
import { computed } from 'vue'
const {
ratio = 'square',
maxWidth,
minWidth
} = defineProps<{
maxWidth?: number
minWidth?: number
const { ratio = 'square' } = defineProps<{
ratio?: 'square' | 'portrait' | 'tallPortrait'
}>()
@@ -30,13 +24,4 @@ const containerClasses = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
const containerStyle = computed(() =>
maxWidth || minWidth
? {
maxWidth: `${maxWidth}px`,
minWidth: `${minWidth}px`
}
: {}
)
</script>

View File

@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { createGridStyle } from '@/utils/gridUtil'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardTop from './CardTop.vue'
const meta: Meta = {
title: 'Components/Card/CardGridList',
tags: ['autodocs'],
argTypes: {
minWidth: {
control: 'text',
description: 'Minimum width for each grid item'
},
maxWidth: {
control: 'text',
description: 'Maximum width for each grid item'
},
padding: {
control: 'text',
description: 'Padding around the grid'
},
gap: {
control: 'text',
description: 'Gap between grid items'
},
columns: {
control: 'number',
description: 'Fixed number of columns (overrides auto-fill)'
}
},
args: {
minWidth: '15rem',
maxWidth: '1fr',
padding: '0rem',
gap: '1rem'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CardContainer, CardTop, CardBottom },
setup() {
const gridStyle = createGridStyle(args)
return { gridStyle }
},
template: `
<div :style="gridStyle">
<CardContainer v-for="i in 12" :key="i" ratio="square">
<template #top>
<CardTop ratio="landscape">
<template #default>
<div class="w-full h-full bg-blue-500"></div>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom class="bg-neutral-200"></CardBottom>
</template>
</CardContainer>
</div>
`
})
}

View File

@@ -3,7 +3,7 @@
<div class="flex gap-2 items-center">
<div
class="preview-box border rounded p-2 w-16 h-16 flex items-center justify-center"
:class="{ 'bg-gray-100 dark:bg-gray-800': !modelValue }"
:class="{ 'bg-gray-100 dark-theme:bg-gray-800': !modelValue }"
>
<img
v-if="modelValue"

View File

@@ -1,4 +1,3 @@
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
<template>
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
<div class="text-2xl font-medium mb-2">

View File

@@ -16,6 +16,21 @@
{{ hint }}
</Message>
<div class="flex gap-4 justify-end">
<div
v-if="type === 'overwriteBlueprint'"
class="flex gap-4 justify-start"
>
<Checkbox
v-model="doNotAskAgain"
class="flex gap-4 justify-start"
input-id="doNotAskAgain"
binary
/>
<label for="doNotAskAgain" severity="secondary">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<Button
:label="$t('g.cancel')"
icon="pi pi-undo"
@@ -38,7 +53,7 @@
@click="onConfirm"
/>
<Button
v-else-if="type === 'overwrite'"
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
:label="$t('g.overwrite')"
severity="warn"
icon="pi pi-save"
@@ -74,10 +89,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
const props = defineProps<{
message: string
@@ -87,14 +106,20 @@ const props = defineProps<{
hint?: string
}>()
const { t } = useI18n()
const onCancel = () => useDialogStore().closeDialog()
const doNotAskAgain = ref(false)
const onDeny = () => {
props.onConfirm(false)
useDialogStore().closeDialog()
}
const onConfirm = () => {
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
props.onConfirm(true)
useDialogStore().closeDialog()
}

View File

@@ -105,7 +105,7 @@ const showContactSupport = async () => {
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
await systemStatsStore.refetchSystemStats()
}
try {

View File

@@ -2,8 +2,8 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Some Nodes Are Missing"
message="When loading the graph, the following node types were not found"
:title="$t('loadWorkflowWarning.missingNodesTitle')"
:message="$t('loadWorkflowWarning.missingNodesDescription')"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
@@ -53,19 +53,15 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
import { useManagerState } from '@/composables/useManagerState'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -81,6 +77,7 @@ const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -111,48 +108,51 @@ const uniqueNodes = computed(() => {
})
})
const managerStateStore = useManagerStateStore()
// Show manager buttons unless manager is disabled
const showManagerButtons = computed(() => {
return managerStateStore.managerUIState !== ManagerUIState.DISABLED
return managerState.shouldShowManagerButtons.value
})
// Only show Install All button for NEW_UI (new manager with v4 support)
const showInstallAllButton = computed(() => {
return managerStateStore.managerUIState === ManagerUIState.NEW_UI
return managerState.shouldShowInstallButton.value
})
const openManager = async () => {
const state = managerStateStore.managerUIState
switch (state) {
case ManagerUIState.DISABLED:
useDialogService().showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
} catch {
// If legacy command doesn't exist, show toast
const { t } = useI18n()
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
break
case ManagerUIState.NEW_UI:
useDialogService().showManagerDialog({
initialTab: ManagerTab.Missing
})
break
}
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
const { t } = useI18n()
const dialogStore = useDialogStore()
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
return (
!isLoading.value &&
!isInstalling.value &&
missingNodePacks.value?.length === 0
)
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
// Show success toast
useToastStore().add({
severity: 'success',
summary: t('g.success'),
detail: t('manager.allMissingNodesInstalled'),
life: 3000
})
}
})
</script>
<style scoped>

View File

@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'

View File

@@ -42,9 +42,8 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Message from 'primevue/message'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -60,20 +59,11 @@ const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
})
const currentComfyUIVersion = ref<string | null>(null)
whenever(
hasMissingCoreNodes,
async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
currentComfyUIVersion.value =
systemStatsStore.systemStats?.system?.comfyui_version ?? null
},
{
immediate: true
}
)
// Use computed for reactive version tracking
const currentComfyUIVersion = computed<string | null>(() => {
if (!hasMissingCoreNodes.value) return null
return systemStatsStore.systemStats?.system?.comfyui_version ?? null
})
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {

View File

@@ -29,7 +29,7 @@
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
class="bg-yellow-500/20 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1">
@@ -46,14 +46,15 @@
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<button
type="button"
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors"
:aria-label="$t('g.close')"
<IconButton
class="absolute top-0 right-0"
type="transparent"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-sm"></i>
</button>
<i
class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
></i>
</IconButton>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
@@ -138,6 +139,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'

View File

@@ -6,7 +6,7 @@ import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerHeader from './ManagerHeader.vue'

View File

@@ -1,11 +1,12 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
@@ -31,11 +32,14 @@ const mockInstalledPacks = {
'installed-pack': { ver: '2.0.0' }
}
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks]
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled
}))
}))
@@ -60,6 +64,7 @@ describe('PackVersionBadge', () => {
beforeEach(() => {
mockToggle.mockReset()
mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
})
const mountComponent = ({
@@ -79,6 +84,9 @@ describe('PackVersionBadge', () => {
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: {
Popover: PopoverStub,
PackVersionSelectorPopover: true
@@ -229,4 +237,63 @@ describe('PackVersionBadge', () => {
expect(mockHide).not.toHaveBeenCalled()
})
})
describe('disabled state', () => {
beforeEach(() => {
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
})
it('adds disabled styles when pack is disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('cursor-not-allowed')
expect(badge.classes()).toContain('opacity-60')
})
it('does not show chevron icon when disabled', () => {
const wrapper = mountComponent()
const chevronIcon = wrapper.find('.pi-chevron-right')
expect(chevronIcon.exists()).toBe(false)
})
it('does not show update arrow when disabled', () => {
const wrapper = mountComponent()
const updateIcon = wrapper.find('.pi-arrow-circle-up')
expect(updateIcon.exists()).toBe(false)
})
it('does not toggle popover when clicked while disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('click')
// Since it's disabled, the popover should not be toggled
expect(mockToggle).not.toHaveBeenCalled()
})
it('has correct tabindex when disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.attributes('tabindex')).toBe('-1')
})
it('does not respond to keyboard events when disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('keydown.enter')
await badge.trigger('keydown.space')
expect(mockToggle).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,21 +1,28 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@click="toggleVersionSelector"
@keydown.enter="toggleVersionSelector"
@keydown.space="toggleVersionSelector"
v-tooltip.top="
isDisabled ? $t('manager.enablePackToChangeVersion') : null
"
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
:class="{
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
'cursor-pointer': !isDisabled,
'cursor-not-allowed opacity-60': isDisabled
}"
:aria-haspopup="!isDisabled"
:role="isDisabled ? 'text' : 'button'"
:tabindex="isDisabled ? -1 : 0"
@click="!isDisabled && toggleVersionSelector($event)"
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
@keydown.space="!isDisabled && toggleVersionSelector($event)"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right text-xxs" />
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div>
<Popover
@@ -61,6 +68,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly'
const version =

View File

@@ -10,7 +10,7 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
// SelectedVersion is now using direct strings instead of enum

View File

@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'

View File

@@ -1,5 +1,8 @@
<template>
<IconTextButton
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
v-bind="$attrs"
type="transparent"
:label="$t('manager.updateAll')"
@@ -24,8 +27,9 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
}>()
const isUpdating = ref<boolean>(false)

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from './DescriptionTabPanel.vue'

View File

@@ -13,7 +13,11 @@
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
<PackEnableToggle v-else :node-pack="nodePack" />
<PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
</div>
</template>

View File

@@ -34,7 +34,8 @@
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks"
:node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/>
</div>
<div class="flex mt-3 text-sm">
@@ -103,8 +104,11 @@ const { t } = useI18n()
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } =
useUpdateAvailableNodes()
const {
hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length

View File

@@ -19,6 +19,7 @@
</template>
<script setup lang="ts" generic="T">
// eslint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
import Dropdown from 'primevue/dropdown'
import type { SearchOption } from '@/types/comfyManagerTypes'

View File

@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import GridSkeleton from './GridSkeleton.vue'
import PackCardSkeleton from './PackCardSkeleton.vue'

View File

@@ -34,7 +34,6 @@
<script setup lang="ts">
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import { onMounted } from 'vue'
import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
@@ -44,10 +43,4 @@ import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore()
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
})
</script>

View File

@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignInForm from './SignInForm.vue'

View File

@@ -28,14 +28,15 @@
id="graph-canvas"
ref="canvasRef"
tabindex="1"
class="w-full h-full touch-none"
class="align-top w-full h-full touch-none"
/>
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="isVueNodesEnabled && canvasStore.canvas && comfyAppReady"
:canvas="canvasStore.canvas as LGraphCanvas"
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
@@ -44,7 +45,6 @@
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:selected="nodeData.selected"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
@@ -79,6 +79,7 @@ import {
computed,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -89,21 +90,16 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import TransformPane from '@/components/graph/TransformPane.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
@@ -116,14 +112,12 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -155,7 +149,8 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const layoutMutations = useLayoutMutations()
const canvasInteractions = useCanvasInteractions()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -172,280 +167,43 @@ const selectionToolboxEnabled = computed(() =>
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags (Vue-related)
// Feature flags
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node lifecycle management - initialize after graph is ready
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
let cleanupNodeManager: (() => void) | null = null
// Slot layout sync management
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
let detectChangesInRAF = () => {}
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
// Initialize node manager when graph becomes available
// Add a reactivity trigger to force computed re-evaluation
const nodeDataTrigger = ref(0)
const initializeNodeManager = () => {
if (!comfyApp.graph || nodeManager) return
nodeManager = useGraphNodeManager(comfyApp.graph)
cleanupNodeManager = nodeManager.cleanup
// Use the manager's reactive maps directly
vueNodeData.value = nodeManager.vueNodeData
nodeState.value = nodeManager.nodeState
nodePositions.value = nodeManager.nodePositions
nodeSizes.value = nodeManager.nodeSizes
detectChangesInRAF = nodeManager.detectChangesInRAF
// Initialize layout system with existing nodes
const nodes = comfyApp.graph._nodes.map((node: any) => ({
id: node.id.toString(),
pos: node.pos,
size: node.size
}))
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of comfyApp.graph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of comfyApp.graph._links.values()) {
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
}
// Initialize layout sync (one-way: Layout Store → LiteGraph)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Initialize slot layout sync for hit detection
slotSync = useSlotLayoutSync()
if (canvasStore.canvas) {
slotSync.start(canvasStore.canvas as LGraphCanvas)
}
// Initialize link layout sync for event-driven updates
linkSync = useLinkLayoutSync()
if (canvasStore.canvas) {
linkSync.start(canvasStore.canvas as LGraphCanvas)
}
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
const disposeNodeManagerAndSyncs = () => {
if (!nodeManager) return
try {
cleanupNodeManager?.()
} catch {
/* empty */
}
nodeManager = null
cleanupNodeManager = null
// Clean up slot layout sync
if (slotSync) {
slotSync.stop()
slotSync = null
}
// Clean up link layout sync
if (linkSync) {
linkSync.stop()
linkSync = null
}
// Reset reactive maps to inert defaults
vueNodeData.value = new Map()
nodeState.value = new Map()
nodePositions.value = new Map()
nodeSizes.value = new Map()
}
// Watch for transformPaneEnabled to gate the node manager lifecycle
watch(
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
(enabled) => {
if (enabled) {
initializeNodeManager()
} else {
disposeNodeManagerAndSyncs()
}
},
{ immediate: true }
)
// Transform state for viewport culling
const { syncWithCanvas } = useTransformState()
const nodesToRender = computed(() => {
// Early return for zero overhead when Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return []
}
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
if (nodeManager && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
})
let lastScale = 1
let lastOffsetX = 0
let lastOffsetY = 0
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const nodesToRender = viewportCulling.nodesToRender
const handleTransformUpdate = () => {
// Skip all work if Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return
}
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
if (
currentScale !== lastScale ||
currentOffsetX !== lastOffsetX ||
currentOffsetY !== lastOffsetY
) {
syncWithCanvas(comfyApp.canvas)
lastScale = currentScale
lastOffsetX = currentOffsetX
lastOffsetY = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Trigger reactivity for nodesToRender
void nodesToRender.value.length
viewportCulling.handleTransformUpdate(
vueNodeLifecycle.detectChangesInRAF.value
)
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Node event handlers
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager) return
const node = nodeManager.getNode(nodeData.id)
if (!node) return
if (!event.ctrlKey && !event.metaKey) {
canvasStore.canvas.deselectAllNodes()
}
canvasStore.canvas.selectNode(node)
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned
if (!node.flags?.pinned) {
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
}
node.selected = true
canvasStore.updateSelectedItems()
}
// Handle node collapse state changes
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) {
node.collapse()
}
}
// Handle node title updates
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Update the node title in LiteGraph for persistence
node.title = newTitle
}
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -653,29 +411,7 @@ onMounted(async () => {
comfyAppReady.value = true
// Set up Vue node initialization only when enabled
if (isVueNodesEnabled.value) {
// Set up a one-time listener for when the first node is added
// This handles the case where Vue nodes are enabled but the graph starts empty
// TODO: Replace this with a reactive graph mutations observer when available
if (comfyApp.graph && !nodeManager && comfyApp.graph._nodes.length === 0) {
const originalOnNodeAdded = comfyApp.graph.onNodeAdded
comfyApp.graph.onNodeAdded = function (node: any) {
// Restore original handler
comfyApp.graph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed
if (isVueNodesEnabled.value && !nodeManager) {
initializeNodeManager()
}
// Call original handler
if (originalOnNodeAdded) {
originalOnNodeAdded.call(this, node)
}
}
}
}
vueNodeLifecycle.setupEmptyGraphListener()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
@@ -719,17 +455,6 @@ onMounted(async () => {
})
onUnmounted(() => {
if (nodeManager) {
nodeManager.cleanup()
nodeManager = null
}
if (slotSync) {
slotSync.stop()
slotSync = null
}
if (linkSync) {
linkSync.stop()
linkSync = null
}
vueNodeLifecycle.cleanup()
})
</script>

View File

@@ -2,12 +2,12 @@
<div
ref="toolboxRef"
style="transform: translate(var(--tb-x), var(--tb-y))"
class="fixed left-0 top-0 z-40"
class="fixed left-0 top-0 z-40 pointer-events-none"
>
<Transition name="slide-up">
<Panel
v-if="visible"
class="rounded-lg selection-toolbox"
class="rounded-lg selection-toolbox pointer-events-auto"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
@@ -21,6 +21,7 @@
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<PublishSubgraphButton />
<DeleteButton />
<RefreshSelectionButton />
<ExtensionCommandButton
@@ -49,6 +50,7 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
@@ -83,7 +85,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
<style scoped>
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
will-change: transform, opacity;
}
@keyframes slideUp {

View File

@@ -147,7 +147,8 @@ watch(
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_PublishSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.PublishSubgraph')"
>
<template #icon>
<i-lucide:book-open />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
</script>

View File

@@ -142,11 +142,12 @@ import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useManagerState } from '@/composables/useManagerState'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -191,7 +192,6 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits
const emit = defineEmits<{
@@ -313,8 +313,11 @@ const menuItems = computed<MenuItem[]>(() => {
icon: PuzzleIcon,
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: () => {
dialogService.showManagerDialog()
action: async () => {
await useManagerState().openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
emit('close')
}
},

View File

@@ -13,6 +13,9 @@ interface ExtendedProps extends Partial<MultiSelectProps> {
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: Array<{ name: string; value: string }>
}
@@ -42,6 +45,18 @@ const meta: Meta<ExtendedProps> = {
},
searchPlaceholder: {
control: 'text'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
},
args: {
@@ -274,3 +289,140 @@ export const CustomSearchPlaceholder: Story = {
searchPlaceholder: 'Filter packages...'
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected1, selected2, selected3, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<MultiSelect
v-model="selected1"
:options="manyOptions"
label="Small Dropdown"
list-max-height="10rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<MultiSelect
v-model="selected2"
:options="manyOptions"
label="Default Dropdown"
list-max-height="28rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<MultiSelect
v-model="selected3"
:options="manyOptions"
label="Large Dropdown"
list-max-height="32rem"
show-selected-count
/>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}

View File

@@ -1,10 +1,9 @@
<template>
<!--
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
@@ -20,12 +19,13 @@
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="p-2 flex flex-col pb-0">
<div class="pt-2 pb-0 px-2 flex flex-col">
<SearchBox
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:show-order="true"
:show-border="true"
:place-holder="searchPlaceholder"
/>
<div
@@ -47,11 +47,11 @@
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm text-blue-500! dark-theme:text-blue-600!"
class="text-sm text-blue-500 dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
<div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
@@ -75,13 +75,13 @@
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" :style="popoverStyle">
<div
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
:class="
slotProps.selected
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'bg-neutral-100 dark-theme:bg-zinc-700'
"
>
<i-lucide:check
@@ -89,9 +89,11 @@
class="text-xs text-bold text-white"
/>
</div>
<Button class="border-none outline-none bg-transparent" unstyled>{{
slotProps.option.name
}}</Button>
<Button
class="border-none outline-none bg-transparent text-left"
unstyled
>{{ slotProps.option.name }}</Button
>
</div>
</template>
</MultiSelect>
@@ -105,6 +107,8 @@ import MultiSelect, {
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import TextButton from '../button/TextButton.vue'
@@ -125,6 +129,12 @@ interface Props {
showClearButton?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
@@ -133,7 +143,10 @@ const {
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...'
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<Option[]>({
@@ -142,10 +155,15 @@ const selectedItems = defineModel<Option[]>({
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
'relative inline-flex cursor-pointer select-none',
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
@@ -170,16 +188,26 @@ const pt = computed(() => ({
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
overlay:
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
overlay: {
class: cn(
'mt-2 rounded-lg py-2 px-2',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
},
listContainer: () => ({
style: { maxHeight: listMaxHeight },
class: 'overflow-y-auto scrollbar-hide'
}),
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: [
'flex gap-1 items-center p-2',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
'flex gap-2 items-center h-10 px-2 rounded-lg',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation
{
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
@@ -189,11 +217,11 @@ const pt = computed(() => ({
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: 'display: none !important'
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: 'display: none !important'
style: { display: 'none' }
}
}))
</script>

View File

@@ -14,11 +14,17 @@ const meta: Meta<typeof SearchBox> = {
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
}
},
args: {
placeHolder: 'Search...',
showBorder: false
showBorder: false,
size: 'md'
}
}
@@ -53,3 +59,27 @@ export const NoBorder: Story = {
showBorder: false
}
}
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -6,7 +6,7 @@
:placeholder="placeHolder || 'Search...'"
type="text"
unstyled
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white"
:class="inputStyle"
/>
</div>
</template>
@@ -15,20 +15,56 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
const { placeHolder, showBorder = false } = defineProps<{
import { cn } from '@/utils/tailwindUtil'
const {
placeHolder,
showBorder = false,
size = 'md'
} = defineProps<{
placeHolder?: string
showBorder?: boolean
size?: 'md' | 'lg'
}>()
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
const wrapperStyle = computed(() => {
return showBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
const baseClasses = [
'relative flex w-full items-center gap-2',
'bg-white dark-theme:bg-zinc-800',
'cursor-text'
]
if (showBorder) {
return cn(
...baseClasses,
'rounded p-2',
'border border-solid',
'border-zinc-200 dark-theme:border-zinc-700'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn(...baseClasses, 'rounded-lg', sizeClasses)
})
const inputStyle = computed(() => {
return cn(
'absolute inset-0 w-full h-full pl-11',
'border-none outline-none bg-transparent',
'text-sm text-neutral dark-theme:text-white'
)
})
const iconColorStyle = computed(() => {
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
return cn(
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
)
})
</script>

View File

@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ArrowUpDown } from 'lucide-vue-next'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
@@ -11,7 +10,19 @@ const meta: Meta<typeof SingleSelect> = {
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
options: { control: 'object' }
options: { control: 'object' },
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
},
args: {
label: 'Sorting Type',
@@ -57,7 +68,7 @@ export const Default: Story = {
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
@@ -67,7 +78,7 @@ export const WithIcon: Story = {
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
@@ -94,7 +105,7 @@ export const Preselected: Story = {
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect, ArrowUpDown },
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
@@ -110,7 +121,7 @@ export const AllVariants: Story = {
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<ArrowUpDown :size="14" />
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>
@@ -122,6 +133,124 @@ export const AllVariants: Story = {
}),
parameters: {
controls: { disable: true },
actions: { disable: true }
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}

View File

@@ -1,10 +1,9 @@
<template>
<!--
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
@@ -18,7 +17,7 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -34,18 +33,19 @@
<!-- Trigger caret -->
<template #dropdownicon>
<i-lucide:chevron-down
class="text-base text-neutral-400 dark-theme:text-gray-300"
/>
<i-lucide:chevron-down class="text-base text-neutral-500" />
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div class="flex items-center justify-between gap-3 w-full">
<div
class="flex items-center justify-between gap-3 w-full"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i-lucide:check
v-if="selected"
class="text-neutral-900 dark-theme:text-white"
class="text-neutral-600 dark-theme:text-white"
/>
</div>
</template>
@@ -56,11 +56,19 @@
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const { label, options } = defineProps<{
const {
label,
options,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
@@ -71,6 +79,12 @@ const { label, options } = defineProps<{
name: string
value: string
}[]
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
const selectedItem = defineModel<string | null>({ required: true })
@@ -87,6 +101,17 @@ const getLabel = (val: string | null | undefined) => {
return found ? found.name : label ?? ''
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
/**
* Unstyled + PT API only
* - No background/border (same as page background)
@@ -98,7 +123,7 @@ const pt = computed(() => ({
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [
// container
'relative inline-flex cursor-pointer select-none items-center',
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-md',
'bg-transparent text-neutral dark-theme:text-white',
@@ -118,23 +143,28 @@ const pt = computed(() => ({
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: [
// dropdown panel
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700'
]
class: cn(
'mt-2 p-2 rounded-lg',
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
},
listContainer: () => ({
style: `max-height: ${listMaxHeight}`,
class: 'overflow-y-auto scrollbar-hide'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-1 p-0 list-none border-none text-xs'
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({
context
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
class: [
// Row layout
'flex items-center justify-between gap-3 px-3 py-2',
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
'flex items-center justify-between gap-3 px-2 py-3 rounded',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation

View File

@@ -88,8 +88,8 @@ const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment()
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed(() => {
const releaseRedDot = showReleaseRedDot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})

View File

@@ -1,6 +1,6 @@
<template>
<div ref="container" class="node-lib-node-container">
<TreeExplorerTreeNode :node="node">
<TreeExplorerTreeNode :node="node" @contextmenu="handleContextMenu">
<template #before-label>
<Tag
v-if="nodeDef.experimental"
@@ -13,7 +13,30 @@
severity="danger"
/>
</template>
<template #actions>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<Button
size="small"
icon="pi pi-trash"
text
severity="danger"
@click.stop="deleteBlueprint"
>
</Button>
<Button
size="small"
text
severity="secondary"
@click.stop="editBlueprint"
>
<template #icon>
<i-lucide:square-pen />
</template>
</Button>
</template>
<template v-else #actions>
<Button
class="bookmark-button"
size="small"
@@ -40,10 +63,13 @@
</div>
</teleport>
</div>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import {
CSSProperties,
@@ -53,14 +79,18 @@ import {
onUnmounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const { t } = useI18n()
const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
@@ -80,6 +110,33 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const toggleBookmark = async () => {
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
const editBlueprint = async () => {
if (!props.node.data)
throw new Error(
'Failed to edit subgraph blueprint lacking backing node data'
)
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
severity: 'error',
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
}
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -28,29 +28,7 @@
@show="onMenuShow"
>
<template #item="{ item, props }">
<div
v-if="item.key === 'theme'"
class="flex items-center gap-4 px-4 py-5"
@click.stop.prevent
>
{{ item.label }}
<SelectButton
:options="[darkLabel, lightLabel]"
:model-value="activeTheme"
@click.stop.prevent
@update:model-value="onThemeChange"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<i v-if="option === lightLabel" class="pi pi-sun" />
<i v-if="option === darkLabel" class="pi pi-moon" />
<span>{{ option }}</span>
</div>
</template>
</SelectButton>
</div>
<a
v-else
class="p-menubar-item-link px-4 py-2"
v-bind="props.action"
:href="item.url"
@@ -95,7 +73,6 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import SelectButton from 'primevue/selectbutton'
import TieredMenu, {
type TieredMenuMethods,
type TieredMenuState
@@ -106,27 +83,28 @@ import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useManagerState } from '@/composables/useManagerState'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const managerState = useManagerState()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
@@ -159,36 +137,33 @@ const showSettings = (defaultPanel?: string) => {
})
}
const managerStateStore = useManagerStateStore()
const showManageExtensions = async () => {
const state = managerStateStore.managerUIState
switch (state) {
case ManagerUIState.DISABLED:
showSettings('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await commandStore.execute('Comfy.Manager.Menu.ToggleVisibility')
} catch {
// If legacy command doesn't exist, fall back to extensions panel
showSettings('extension')
}
break
case ManagerUIState.NEW_UI:
useDialogService().showManagerDialog()
break
}
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
}
const extraMenuItems = computed<MenuItem[]>(() => [
const themeMenuItems = computed(() => {
return colorPaletteStore.palettes.map((palette) => ({
key: `theme-${palette.id}`,
label: palette.name,
parentPath: 'theme',
comfyCommand: {
active: () => colorPaletteStore.activePaletteId === palette.id
},
command: async () => {
await colorPaletteService.loadColorPalette(palette.id)
}
}))
})
const extraMenuItems = computed(() => [
{ separator: true },
{
key: 'theme',
label: t('menu.theme')
label: t('menu.theme'),
items: themeMenuItems.value
},
{ separator: true },
{
@@ -211,19 +186,6 @@ const extraMenuItems = computed<MenuItem[]>(() => [
}
])
const lightLabel = computed(() => t('menu.light'))
const darkLabel = computed(() => t('menu.dark'))
const activeTheme = computed(() => {
return colorPaletteStore.completedActivePalette.light_theme
? lightLabel.value
: darkLabel.value
})
const onThemeChange = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
const translatedItems = computed(() => {
const items = menuItemsStore.menuItems.map(translateMenuItem)
let helpIndex = items.findIndex((item) => item.key === 'Help')
@@ -308,7 +270,12 @@ const handleItemClick = (item: MenuItem, event: MouseEvent) => {
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
// Check if this item has siblings with active state (either from store or theme items)
return (
item.parentPath &&
(item.parentPath === 'theme' ||
menuItemsStore.menuItemHasActiveStateChildren[item.parentPath])
)
}
</script>
@@ -332,6 +299,18 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
</style>
<style>
.comfy-command-menu {
--p-tieredmenu-item-focus-background: color-mix(
in srgb,
var(--fg-color) 15%,
transparent
);
--p-tieredmenu-item-active-background: color-mix(
in srgb,
var(--fg-color) 10%,
transparent
);
}
.comfy-command-menu ul {
background-color: var(--comfy-menu-secondary-bg) !important;
}

View File

@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserButton from './CurrentUserButton.vue'

View File

@@ -4,7 +4,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits
} from 'reka-ui'
import { type HTMLAttributes, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
SliderRootProps & { class?: HTMLAttributes['class'] }
>()
const pressed = ref(false)
const setPressed = (val: boolean) => {
pressed.value = val
}
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="
cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
props.class
)
"
v-bind="forwarded"
@slide-start="() => setPressed(true)"
@slide-move="() => setPressed(true)"
@slide-end="() => setPressed(false)"
>
<SliderTrack
data-slot="slider-track"
:class="
cn(
'bg-node-stroke relative grow overflow-hidden rounded-full',
'cursor-pointer',
'data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-full',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-0.5'
)
"
>
<SliderRange
data-slot="slider-range"
class="bg-node-component-surface-highlight absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
:class="
cn(
'bg-node-component-surface-highlight ring-node-component-surface-selected block size-3.5 shrink-0 rounded-full shadow-sm transition-[color,box-shadow]',
'cursor-grab',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
/>
</SliderRoot>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<BaseWidgetLayout :content-title="$t('Checkpoints')">
<BaseModalLayout :content-title="$t('Checkpoints')">
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
@@ -12,7 +12,7 @@
</template>
<template #header>
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -56,7 +56,7 @@
</template>
<template #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<div class="relative px-6 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
v-model:search-query="searchText"
@@ -87,16 +87,8 @@
<template #content>
<!-- Card Examples -->
<!-- <div class="min-h-0 px-6 py-4 overflow-y-auto scrollbar-hide"> -->
<!-- <h2 class="text-xxl py-4 pt-0 m-0">{{ $t('Checkpoints') }}</h2> -->
<div class="flex flex-wrap gap-2">
<CardContainer
v-for="i in 100"
:key="i"
ratio="square"
:max-width="480"
:min-width="230"
>
<div :style="gridStyle">
<CardContainer v-for="i in 100" :key="i" ratio="square">
<template #top>
<CardTop ratio="landscape">
<template #default>
@@ -126,17 +118,16 @@
</template>
</CardContainer>
</div>
<!-- </div> -->
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
</template>
</BaseWidgetLayout>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -149,11 +140,12 @@ 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 BaseWidgetLayout from '@/components/widget/layout/BaseWidgetLayout.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
@@ -175,20 +167,20 @@ const sortOptions = ref([
])
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{ id: 'installed', label: 'Installed', icon: 'icon-[lucide--download]' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
{ id: 'tag-sd15', label: 'SD 1.5', icon: 'icon-[lucide--tag]' },
{ id: 'tag-sdxl', label: 'SDXL', icon: 'icon-[lucide--tag]' },
{ id: 'tag-utility', label: 'Utility', icon: 'icon-[lucide--tag]' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
{ id: 'cat-models', label: 'Models', icon: 'icon-[lucide--layers]' },
{ id: 'cat-nodes', label: 'Nodes', icon: 'icon-[lucide--grid-3x3]' }
]
}
])
@@ -209,6 +201,8 @@ const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed')
const gridStyle = computed(() => createGridStyle())
watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery)
})

View File

@@ -1,20 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
Download,
Filter,
Folder,
Info,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
Puzzle,
Scroll,
Settings,
Upload,
X
} from 'lucide-vue-next'
import { provide, ref } from 'vue'
import { computed, provide, ref } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -28,10 +13,11 @@ import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseWidgetLayout from './BaseWidgetLayout.vue'
import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs {
contentTitle: string
@@ -44,7 +30,7 @@ interface StoryArgs {
}
const meta: Meta<StoryArgs> = {
title: 'Components/Widget/Layout/BaseWidgetLayout',
title: 'Components/Widget/Layout/BaseModalLayout',
argTypes: {
contentTitle: {
control: 'text',
@@ -82,7 +68,7 @@ type Story = StoryObj<typeof meta>
const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseWidgetLayout,
BaseModalLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
@@ -94,20 +80,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip,
Settings,
Upload,
Download,
Scroll,
Info,
Filter,
Folder,
Puzzle,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X
SquareChip
},
setup() {
const t = (k: string) => k
@@ -118,20 +91,44 @@ const createStoryTemplate = (args: StoryArgs) => ({
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--folder]'
},
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
{
id: 'tag-sd15',
label: 'SD 1.5',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-sdxl',
label: 'SDXL',
icon: 'icon-[lucide--tag]'
},
{
id: 'tag-utility',
label: 'Utility',
icon: 'icon-[lucide--tag]'
}
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
{
id: 'cat-models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
}
])
@@ -160,6 +157,8 @@ const createStoryTemplate = (args: StoryArgs) => ({
const selectedProjects = ref<string[]>([])
const selectedSort = ref<string>('popular')
const gridStyle = computed(() => createGridStyle())
return {
args,
t,
@@ -171,17 +170,18 @@ const createStoryTemplate = (args: StoryArgs) => ({
sortOptions,
selectedFrameworks,
selectedProjects,
selectedSort
selectedSort,
gridStyle
}
},
template: `
<div>
<BaseWidgetLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -193,6 +193,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
@@ -203,7 +204,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -215,7 +216,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -225,7 +226,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -235,7 +236,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<div class="relative px-6 py-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
@@ -256,7 +257,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -264,7 +265,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<div :style="gridStyle">
<CardContainer
v-for="i in args.cardCount"
:key="i"
@@ -277,7 +278,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -285,7 +286,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -297,15 +298,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
</CardContainer>
</div>
</template>
</BaseWidgetLayout>
</BaseModalLayout>
<BaseWidgetLayout v-else :content-title="args.contentTitle || 'Content Title'">
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<Puzzle :size="16" class="text-neutral" />
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
@@ -317,6 +318,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template v-if="args.hasHeader" #header>
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
@@ -327,7 +329,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<div class="flex gap-2">
<IconTextButton type="primary" label="Upload Model" @click="() => {}">
<template #icon>
<Upload :size="12" />
<i class="icon-[lucide--upload] size-3" />
</template>
</IconTextButton>
@@ -339,7 +341,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Download :size="12" />
<i class="icon-[lucide--download] size-3" />
</template>
</IconTextButton>
@@ -349,7 +351,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
@click="() => { close() }"
>
<template #icon>
<Scroll :size="12" />
<i class="icon-[lucide--scroll] size-3" />
</template>
</IconTextButton>
</template>
@@ -359,7 +361,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content Filter -->
<template v-if="args.hasContentFilter" #contentFilter>
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<div class="relative px-6 py-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
label="Select Frameworks"
@@ -377,7 +379,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
class="w-[135px]"
>
<template #icon>
<Filter :size="12" />
<i class="icon-[lucide--filter] size-3" />
</template>
</SingleSelect>
</div>
@@ -385,7 +387,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Content -->
<template #content>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
<div :style="gridStyle">
<CardContainer
v-for="i in args.cardCount"
:key="i"
@@ -398,7 +400,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
</template>
<template #top-right>
<IconButton class="!bg-white !text-neutral-900" @click="() => {}">
<Info :size="16" />
<i class="icon-[lucide--info] size-4" />
</IconButton>
</template>
<template #bottom-right>
@@ -406,7 +408,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<template #icon>
<Folder :size="12" />
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</template>
@@ -423,7 +425,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<template #rightPanel>
<RightSidePanel />
</template>
</BaseWidgetLayout>
</BaseModalLayout>
</div>
`
})

View File

@@ -1,21 +1,13 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-50 dark-theme:bg-zinc-800"
>
<div :class="layoutClasses">
<IconButton
v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
:class="rightPanelButtonClasses"
@click="toggleRightPanel"
>
<i-lucide:panel-right class="text-sm" />
</IconButton>
<IconButton
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<IconButton :class="closeButtonClasses" @click="closeDialog">
<i class="pi pi-times text-sm"></i>
</IconButton>
<div class="flex w-full h-full">
@@ -32,12 +24,9 @@
</nav>
</Transition>
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
<div :class="mainContainerClasses">
<div class="w-full h-full flex flex-col">
<header
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<header v-if="$slots.header" :class="headerClasses">
<div class="flex-1 flex gap-2 shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
@@ -46,12 +35,7 @@
<slot name="header"></slot>
</div>
<slot name="header-right-area"></slot>
<div
class="flex justify-end gap-2 w-0"
:class="
hasRightPanel && !isRightPanelOpen ? 'min-w-18' : 'min-w-8'
"
>
<div :class="rightAreaClasses">
<IconButton
v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel"
@@ -67,14 +51,14 @@
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
{{ contentTitle }}
</h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto scrollbar-hide">
<div :class="contentContainerClasses">
<slot name="content"></slot>
</div>
</main>
</div>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
:class="rightPanelClasses"
>
<slot name="rightPanel"></slot>
</aside>
@@ -89,6 +73,7 @@ import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { contentTitle } = defineProps<{
contentTitle: string
@@ -137,6 +122,50 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
// Computed classes for better readability
const layoutClasses = cn(
'base-widget-layout',
'rounded-2xl overflow-hidden relative',
'bg-zinc-50 dark-theme:bg-zinc-800'
)
const rightPanelButtonClasses = computed(() => {
return cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none':
isRightPanelOpen.value || !hasRightPanel.value
})
})
const closeButtonClasses = cn(
'absolute top-4 right-6 z-10',
'transition-opacity duration-200'
)
const mainContainerClasses = cn(
'flex-1 flex',
'bg-zinc-100 dark-theme:bg-neutral-900'
)
const headerClasses = cn(
'w-full h-18 px-6',
'flex items-center justify-between gap-2'
)
const rightAreaClasses = computed(() => {
return cn(
'flex justify-end gap-2 w-0',
hasRightPanel.value && !isRightPanelOpen.value ? 'min-w-22' : 'min-w-10'
)
})
const contentContainerClasses = computed(() => {
return cn('min-h-0 px-6 pt-0 pb-10', 'overflow-y-auto scrollbar-hide')
})
const rightPanelClasses = computed(() => {
return cn('w-1/4 min-w-40 max-w-80')
})
</script>
<style scoped>
.base-widget-layout {

View File

@@ -0,0 +1,11 @@
<template>
<i :class="icon" class="text-xs text-neutral" />
</template>
<script setup lang="ts">
import { NavItemData } from '@/types/navTypes'
defineProps<{
icon: NavItemData['icon']
}>()
</script>

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