Compare commits

..

48 Commits

Author SHA1 Message Date
snomiao
a9b454dbe9 chore(package.json): remove pnpx from collect-i18n script for simplicity and consistency 2025-09-12 06:36:24 +00:00
snomiao
d1e668abb9 fix: Remove npm warnings in collect-i18n script
Replace npx with direct playwright binary call to avoid npm v11 warnings
about unknown env configs (verify-deps-before-run and _jsr-registry).
These configs are passed from pnpm environment but not recognized by npm.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 06:10:10 +00: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
151 changed files with 4902 additions and 2282 deletions

View File

@@ -133,11 +133,10 @@ jobs:
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}"

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

@@ -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

@@ -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

@@ -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

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: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.2",
"version": "1.27.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -25,20 +25,21 @@
"preinstall": "npx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"lint": "eslint src --cache --concurrency=auto",
"lint:fix": "eslint src --cache --fix --concurrency=auto",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"storybook": "nx storybook -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@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",
@@ -76,7 +77,6 @@
"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",

22
pnpm-lock.yaml generated
View File

@@ -171,6 +171,9 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.12.0
'@iconify-json/lucide':
specifier: ^1.2.66
version: 1.2.66
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
@@ -282,9 +285,6 @@ importers:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
lucide-vue-next:
specifier: ^0.540.0
version: 0.540.0(vue@3.5.13(typescript@5.9.2))
nx:
specifier: 21.4.1
version: 21.4.1
@@ -1595,6 +1595,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/lucide@1.2.66':
resolution: {integrity: sha512-TrhmfThWY2FHJIckjz7g34gUx3+cmja61DcHNdmu0rVDBQHIjPMYO1O8mMjoDSqIXEllz9wDZxCqT3lFuI+f/A==}
'@iconify/json@2.2.380':
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
@@ -4736,11 +4739,6 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lucide-vue-next@0.540.0:
resolution: {integrity: sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw==}
peerDependencies:
vue: '>=3.0.1'
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -8024,6 +8022,10 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/lucide@1.2.66':
dependencies:
'@iconify/types': 2.0.0
'@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
@@ -11563,10 +11565,6 @@ snapshots:
lru-cache@8.0.5: {}
lucide-vue-next@0.540.0(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)
lz-string@1.5.0: {}
magic-string@0.30.17:

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

@@ -7,66 +7,6 @@
@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 +47,91 @@
}
}
@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);
}
@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 +874,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 +887,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 +964,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 +998,3 @@ audio.comfy-audio.empty-audio-widget {
/* Use solid colors only */
background-image: none !important;
}

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

@@ -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,13 +53,16 @@
<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 { useManagerState } from '@/composables/useManagerState'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -121,6 +124,35 @@ const openManager = async () => {
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

@@ -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

@@ -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

@@ -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

@@ -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,7 +28,7 @@
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 -->
@@ -36,6 +36,7 @@
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
@@ -96,7 +97,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -116,6 +117,7 @@ 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'
@@ -147,6 +149,8 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const canvasInteractions = useCanvasInteractions()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -191,19 +195,15 @@ const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = ref(new Set<string>())
provide(SelectedNodeIdsKey, selectedNodeIds)
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {
selectedNodeIds.value = new Set(
newSelectedItems
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
},
{ immediate: true }
)
provide(SelectedNodeIdsKey, selectedNodeIds)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

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

@@ -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

@@ -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>

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NavItem from './NavItem.vue'
const meta: Meta<typeof NavItem> = {
title: 'Components/Widget/Nav/NavItem',
component: NavItem,
argTypes: {
icon: {
control: 'select',
description: 'Icon component to display'
},
active: {
control: 'boolean',
description: 'Active state of the nav item'
},
onClick: {
table: { disable: true }
},
default: {
control: 'text',
description: 'Text content for the nav item'
}
},
args: {
active: false,
onClick: () => {},
default: 'Navigation Item'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const InteractiveList: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-1">
<NavItem
v-for="item in items"
:key="item.id"
:icon="item.icon"
:active="selectedId === item.id"
:on-click="() => selectedId = item.id"
>
{{ item.label }}
</NavItem>
</div>
`,
data() {
return {
selectedId: 'downloads'
}
},
setup() {
const items = [
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
},
{
id: 'tags',
label: 'Tags',
icon: 'icon-[lucide--tag]'
},
{
id: 'settings',
label: 'Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'default',
label: 'Default Icon',
icon: 'icon-[lucide--folder]'
}
]
return { items }
}
}),
parameters: {
controls: { disable: true }
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
:class="
active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
@@ -9,7 +9,8 @@
role="button"
@click="onClick"
>
<i-lucide:folder v-if="hasFolderIcon" class="text-xs text-neutral" />
<NavIcon v-if="icon" :icon="icon" />
<i-lucide:folder v-else class="text-xs text-neutral" />
<span class="flex items-center">
<slot></slot>
</span>
@@ -17,12 +18,12 @@
</template>
<script setup lang="ts">
const {
hasFolderIcon = true,
active,
onClick
} = defineProps<{
hasFolderIcon?: boolean
import { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
const { icon, active, onClick } = defineProps<{
icon: NavItemData['icon']
active?: boolean
onClick: () => void
}>()

View File

@@ -1,6 +1,6 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-xxs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
class="m-0 px-3 py-0 pt-5 text-xs font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>

View File

@@ -1,138 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
BarChart3,
Bell,
BookOpen,
FolderOpen,
GraduationCap,
Home,
LogOut,
MessageSquare,
Settings,
User,
Users
} from 'lucide-vue-next'
import { ref } from 'vue'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import NavItem from './NavItem.vue'
import NavTitle from './NavTitle.vue'
const meta: Meta = {
title: 'Components/Widget/Navigation',
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const NavigationItem: Story = {
render: () => ({
components: { NavItem },
template: `
<div class="space-y-2">
<NavItem>Dashboard</NavItem>
<NavItem>Projects</NavItem>
<NavItem>Messages</NavItem>
<NavItem>Settings</NavItem>
</div>
`
})
}
export const CustomNavigation: Story = {
render: () => ({
components: {
NavTitle,
NavItem,
Home,
FolderOpen,
BarChart3,
Users,
BookOpen,
GraduationCap,
MessageSquare,
Settings,
User,
Bell,
LogOut
},
template: `
<nav class="w-64 p-4 bg-white dark-theme:bg-zinc-800 rounded-lg">
<NavTitle title="Main Menu" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Home :size="16" class="inline mr-2" />Dashboard</NavItem>
<NavItem :hasFolderIcon="false"><FolderOpen :size="16" class="inline mr-2" />Projects</NavItem>
<NavItem :hasFolderIcon="false"><BarChart3 :size="16" class="inline mr-2" />Analytics</NavItem>
<NavItem :hasFolderIcon="false"><Users :size="16" class="inline mr-2" />Team</NavItem>
</div>
<div class="mt-6">
<NavTitle title="Resources" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><BookOpen :size="16" class="inline mr-2" />Documentation</NavItem>
<NavItem :hasFolderIcon="false"><GraduationCap :size="16" class="inline mr-2" />Tutorials</NavItem>
<NavItem :hasFolderIcon="false"><MessageSquare :size="16" class="inline mr-2" />Community</NavItem>
</div>
</div>
<div class="mt-6">
<NavTitle title="Account" />
<div class="mt-4 space-y-2">
<NavItem :hasFolderIcon="false"><Settings :size="16" class="inline mr-2" />Settings</NavItem>
<NavItem :hasFolderIcon="false"><User :size="16" class="inline mr-2" />Profile</NavItem>
<NavItem :hasFolderIcon="false"><Bell :size="16" class="inline mr-2" />Notifications</NavItem>
<NavItem :hasFolderIcon="false"><LogOut :size="16" class="inline mr-2" />Logout</NavItem>
</div>
</div>
</nav>
`
})
}
export const LeftSidePanelDemo: Story = {
render: () => ({
components: { LeftSidePanel, FolderOpen },
setup() {
const navItems = [
{
title: 'Workspace',
items: [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'projects', label: 'Projects' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'models', label: 'Models' }
]
},
{
title: 'Tools',
items: [
{ id: 'node-editor', label: 'Node Editor' },
{ id: 'image-browser', label: 'Image Browser' },
{ id: 'queue-manager', label: 'Queue Manager' },
{ id: 'extensions', label: 'Extensions' }
]
},
{ id: 'settings', label: 'Settings' }
]
const active = ref<string | null>(null)
return { navItems, active }
},
template: `
<div class="w-full h-[560px] flex">
<div class="w-64 rounded-lg">
<LeftSidePanel v-model="active" :nav-items="navItems">
<template #header-icon>
<FolderOpen :size="14" />
</template>
<template #header-title>
Navigation
</template>
</LeftSidePanel>
</div>
<div class="flex-1 p-3 text-sm bg-gray-50 dark-theme:bg-zinc-900 border-t border-zinc-200 dark-theme:border-zinc-700">
Active: {{ active ?? 'None' }}
</div>
</div>
`
})
}

View File

@@ -0,0 +1,242 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import LeftSidePanel from './LeftSidePanel.vue'
const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: 'installed',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
id: 'models',
label: 'Models',
icon: 'icon-[lucide--layers]'
},
{
id: 'nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const WithGroups: Story = {
args: {
modelValue: 'tag-sd15',
navItems: [
{
id: 'installed',
label: 'Installed',
icon: 'icon-[lucide--download]'
},
{
title: 'TAGS',
items: [
{
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',
icon: 'icon-[lucide--layers]'
},
{
id: 'cat-nodes',
label: 'Nodes',
icon: 'icon-[lucide--grid-3x3]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
</div>
`
})
}
export const DefaultIcons: Story = {
args: {
modelValue: 'home',
navItems: [
{
id: 'home',
label: 'Home',
icon: 'icon-[lucide--folder]'
},
{
id: 'documents',
label: 'Documents',
icon: 'icon-[lucide--folder]'
},
{
id: 'downloads',
label: 'Downloads',
icon: 'icon-[lucide--folder]'
},
{
id: 'desktop',
label: 'Desktop',
icon: 'icon-[lucide--folder]'
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
</div>
`
})
}
export const LongLabels: Story = {
args: {
modelValue: 'general',
navItems: [
{
id: 'general',
label: 'General Settings',
icon: 'icon-[lucide--wrench]'
},
{
id: 'appearance',
label: 'Appearance & Themes Configuration',
icon: 'icon-[lucide--wrench]'
},
{
title: 'ADVANCED OPTIONS',
items: [
{
id: 'performance',
label: 'Performance & Optimization Settings',
icon: 'icon-[lucide--zap]'
},
{
id: 'experimental',
label: 'Experimental Features (Beta)',
icon: 'icon-[lucide--puzzle]'
}
]
}
]
},
render: (args) => ({
components: { LeftSidePanel },
setup() {
const selectedItem = ref(args.modelValue)
return { args, selectedItem }
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
</div>
`
})
}

View File

@@ -14,6 +14,7 @@
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
@@ -22,6 +23,7 @@
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:active="activeItem === item.id"
@click="activeItem = item.id"
>

View File

@@ -123,12 +123,14 @@ export function useSelectedLiteGraphItems() {
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
// Process each selected node independently to determine its target state and apply to children
selectedNodeArray.forEach((selectedNode) => {
// Apply standard toggle logic to the selected node itself
const newModeForSelectedNode =
selectedNode.mode === mode ? LGraphEventMode.ALWAYS : mode
selectedNode.mode = newModeForSelectedNode

View File

@@ -3,8 +3,12 @@ import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/stores/graphStore'
import { computeUnionBounds } from '@/utils/mathUtil'
/**
* Manages the position of the selection toolbox independently.
@@ -16,6 +20,7 @@ export function useSelectionToolboxPosition(
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { getSelectableItems } = useSelectedLiteGraphItems()
const { shouldRenderVueNodes } = useVueFeatureFlags()
// World position of selection center
const worldPosition = ref({ x: 0, y: 0 })
@@ -34,17 +39,40 @@ export function useSelectionToolboxPosition(
}
visible.value = true
const bounds = createBounds(selectableItems)
if (!bounds) {
return
// Get bounds for all selected items
const allBounds: ReadOnlyRect[] = []
for (const item of selectableItems) {
// Skip items without valid IDs
if (item.id == null) continue
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
// Use layout store for Vue nodes (only works with string IDs)
const layout = layoutStore.getNodeLayoutRef(item.id).value
if (layout) {
allBounds.push([
layout.bounds.x,
layout.bounds.y,
layout.bounds.width,
layout.bounds.height
])
}
} else {
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
if (item instanceof LGraphNode) {
const bounds = item.getBounding()
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
}
}
}
const [xBase, y, width] = bounds
// Compute union bounds
const unionBounds = computeUnionBounds(allBounds)
if (!unionBounds) return
worldPosition.value = {
x: xBase + width / 2,
y: y
x: unionBounds.x + unionBounds.width / 2,
y: unionBounds.y - 10
}
updateTransform()

View File

@@ -24,14 +24,12 @@ export function useCanvasInteractions() {
const handleWheel = (event: WheelEvent) => {
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
event.preventDefault() // Prevent browser zoom
forwardEventToCanvas(event)
return
}
// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) {
event.preventDefault()
forwardEventToCanvas(event)
return
}
@@ -68,9 +66,30 @@ export function useCanvasInteractions() {
) => {
const canvasEl = app.canvas?.canvas
if (!canvasEl) return
event.preventDefault()
event.stopPropagation()
if (event instanceof WheelEvent) {
const { clientX, clientY, deltaX, deltaY, ctrlKey, metaKey, shiftKey } =
event
canvasEl.dispatchEvent(
new WheelEvent('wheel', {
clientX,
clientY,
deltaX,
deltaY,
ctrlKey,
metaKey,
shiftKey
})
)
return
}
// Create new event with same properties
const EventConstructor = event.constructor as typeof WheelEvent
const EventConstructor = event.constructor as
| typeof MouseEvent
| typeof PointerEvent
const newEvent = new EventConstructor(event.type, event)
canvasEl.dispatchEvent(newEvent)
}

View File

@@ -4,6 +4,7 @@
*/
import { nextTick, reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
@@ -595,6 +596,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
@@ -618,27 +620,48 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index for viewport culling
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
nodePositions.set(id, nodePosition)
nodeSizes.set(id, nodeSize)
attachMetadata(node)
// Add to spatial index for viewport culling with final positions
const nodeBounds: Bounds = {
x: nodePosition.x,
y: nodePosition.y,
width: nodeSize.width,
height: nodeSize.height
}
spatialIndex.insert(id, nodeBounds, id)
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
spatialIndex.insert(id, bounds, id)
// Add node to layout store
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
initializeVueNodeLayout
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {

View File

@@ -1511,6 +1511,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return 'Token-based'
}
},
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
const maxImagesWidget = node.widgets?.find(
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
}
const maxImages = Number(maxImagesWidget.value)
if (maxImages === 1) {
return '$0.03/Run'
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
}
},
ByteDanceTextToVideoNode: {
displayPrice: byteDanceVideoPricingCalculator
},
@@ -1613,6 +1639,11 @@ export const useNodePricing = () => {
// ByteDance
ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model'],
ByteDanceSeedreamNode: [
'model',
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],

View File

@@ -44,9 +44,24 @@ export const useUpdateAvailableNodes = () => {
return filterOutdatedPacks(installedPacks.value)
})
// Check if there are any outdated packs
// Filter only enabled outdated packs
const enabledUpdateAvailableNodePacks = computed(() => {
return updateAvailableNodePacks.value.filter((pack) =>
comfyManagerStore.isPackEnabled(pack.id)
)
})
// Check if there are any enabled outdated packs
const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0
return enabledUpdateAvailableNodePacks.value.length > 0
})
// Check if there are disabled packs with updates
const hasDisabledUpdatePacks = computed(() => {
return (
updateAvailableNodePacks.value.length >
enabledUpdateAvailableNodePacks.value.length
)
})
// Automatically fetch installed pack data when composable is used
@@ -58,7 +73,9 @@ export const useUpdateAvailableNodes = () => {
return {
updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading,
error
}

View File

@@ -0,0 +1,30 @@
import { type CSSProperties, type ComputedRef, computed } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
* @returns Computed style object for popover sizing
*/
export function usePopoverSizing(
options: PopoverSizeOptions
): ComputedRef<CSSProperties> {
return computed(() => {
const { minWidth, maxWidth } = options
const style: CSSProperties = {}
if (minWidth) {
style.minWidth = minWidth
}
if (maxWidth) {
style.maxWidth = maxWidth
}
return style
})
}

View File

@@ -0,0 +1,48 @@
import type { HintedString } from '@primevue/core'
import { computed } from 'vue'
/**
* Options for configuring transform-compatible overlay props
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
return computed(() => ({
appendTo: 'self' as const,
...overrides
}))
}

View File

@@ -1,9 +1,9 @@
import arc from '@/assets/palettes/arc.json'
import dark from '@/assets/palettes/dark.json'
import github from '@/assets/palettes/github.json'
import light from '@/assets/palettes/light.json'
import nord from '@/assets/palettes/nord.json'
import solarized from '@/assets/palettes/solarized.json'
import arc from '@/assets/palettes/arc.json' with { type: 'json' }
import dark from '@/assets/palettes/dark.json' with { type: 'json' }
import github from '@/assets/palettes/github.json' with { type: 'json' }
import light from '@/assets/palettes/light.json' with { type: 'json' }
import nord from '@/assets/palettes/nord.json' with { type: 'json' }
import solarized from '@/assets/palettes/solarized.json' with { type: 'json' }
import type {
ColorPalettes,
CompletedPalette

View File

@@ -972,5 +972,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
tooltip: 'Use new Asset API for model browsing',
defaultValue: false,
experimental: true
}
]

View File

@@ -1,41 +1,41 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json'
import ar from './locales/ar/main.json'
import arNodes from './locales/ar/nodeDefs.json'
import arSettings from './locales/ar/settings.json'
import enCommands from './locales/en/commands.json'
import en from './locales/en/main.json'
import enNodes from './locales/en/nodeDefs.json'
import enSettings from './locales/en/settings.json'
import esCommands from './locales/es/commands.json'
import es from './locales/es/main.json'
import esNodes from './locales/es/nodeDefs.json'
import esSettings from './locales/es/settings.json'
import frCommands from './locales/fr/commands.json'
import fr from './locales/fr/main.json'
import frNodes from './locales/fr/nodeDefs.json'
import frSettings from './locales/fr/settings.json'
import jaCommands from './locales/ja/commands.json'
import ja from './locales/ja/main.json'
import jaNodes from './locales/ja/nodeDefs.json'
import jaSettings from './locales/ja/settings.json'
import koCommands from './locales/ko/commands.json'
import ko from './locales/ko/main.json'
import koNodes from './locales/ko/nodeDefs.json'
import koSettings from './locales/ko/settings.json'
import ruCommands from './locales/ru/commands.json'
import ru from './locales/ru/main.json'
import ruNodes from './locales/ru/nodeDefs.json'
import ruSettings from './locales/ru/settings.json'
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
import zhCommands from './locales/zh/commands.json'
import zh from './locales/zh/main.json'
import zhNodes from './locales/zh/nodeDefs.json'
import zhSettings from './locales/zh/settings.json'
import arCommands from './locales/ar/commands.json' with { type: 'json' }
import ar from './locales/ar/main.json' with { type: 'json' }
import arNodes from './locales/ar/nodeDefs.json' with { type: 'json' }
import arSettings from './locales/ar/settings.json' with { type: 'json' }
import enCommands from './locales/en/commands.json' with { type: 'json' }
import en from './locales/en/main.json' with { type: 'json' }
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from './locales/en/settings.json' with { type: 'json' }
import esCommands from './locales/es/commands.json' with { type: 'json' }
import es from './locales/es/main.json' with { type: 'json' }
import esNodes from './locales/es/nodeDefs.json' with { type: 'json' }
import esSettings from './locales/es/settings.json' with { type: 'json' }
import frCommands from './locales/fr/commands.json' with { type: 'json' }
import fr from './locales/fr/main.json' with { type: 'json' }
import frNodes from './locales/fr/nodeDefs.json' with { type: 'json' }
import frSettings from './locales/fr/settings.json' with { type: 'json' }
import jaCommands from './locales/ja/commands.json' with { type: 'json' }
import ja from './locales/ja/main.json' with { type: 'json' }
import jaNodes from './locales/ja/nodeDefs.json' with { type: 'json' }
import jaSettings from './locales/ja/settings.json' with { type: 'json' }
import koCommands from './locales/ko/commands.json' with { type: 'json' }
import ko from './locales/ko/main.json' with { type: 'json' }
import koNodes from './locales/ko/nodeDefs.json' with { type: 'json' }
import koSettings from './locales/ko/settings.json' with { type: 'json' }
import ruCommands from './locales/ru/commands.json' with { type: 'json' }
import ru from './locales/ru/main.json' with { type: 'json' }
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
import zhTWSettings from './locales/zh-TW/settings.json' with { type: 'json' }
import zhCommands from './locales/zh/commands.json' with { type: 'json' }
import zh from './locales/zh/main.json' with { type: 'json' }
import zhNodes from './locales/zh/nodeDefs.json' with { type: 'json' }
import zhSettings from './locales/zh/settings.json' with { type: 'json' }
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
return {

View File

@@ -6,6 +6,7 @@ import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { CanvasPointer } from './CanvasPointer'
@@ -5559,7 +5560,9 @@ export class LGraphCanvas
const link = graph._links.get(link_id)
if (!link) continue
const endPos = node.getInputPos(i)
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(node, i, true)
: node.getInputPos(i)
// find link info
const start_node = graph.getNodeById(link.origin_id)
@@ -5569,7 +5572,9 @@ export class LGraphCanvas
const startPos: Point =
outputId === -1
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
: start_node.getOutputPos(outputId)
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(start_node, outputId, false)
: start_node.getOutputPos(outputId)
const output = start_node.outputs[outputId]
if (!output) continue

View File

@@ -3833,33 +3833,12 @@ export class LGraphNode
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
if (LiteGraph.vueNodesMode) {
// Vue-based slot dimensions
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
if (slot.isWidgetInputSlot) {
// Widget slots have a 20x20 clickable area centered at the position
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - 10
slot.boundingRect[2] = 20
slot.boundingRect[3] = 20
} else {
// Regular slots have a 20x20 clickable area for the connector
// but the full slot height for vertical spacing
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
slot.boundingRect[2] = 20
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
}
} else {
// Traditional LiteGraph dimensions
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
#measureSlots(): ReadOnlyRect | null {

View File

@@ -24,26 +24,6 @@ import {
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
* for the positioning contract to work correctly.
*/
export const COMFY_VUE_NODE_DIMENSIONS = {
spacing: {
BETWEEN_SLOTS_AND_BODY: 8,
BETWEEN_WIDGETS: 8
},
components: {
HEADER_HEIGHT: 34, // 18 header + 16 padding
SLOT_HEIGHT: 24,
STANDARD_WIDGET_HEIGHT: 30
}
} as const
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -95,14 +75,6 @@ export class LiteGraphGlobal {
WIDGET_SECONDARY_TEXT_COLOR = '#999'
WIDGET_DISABLED_TEXT_COLOR = '#666'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*/
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'

View File

@@ -104,7 +104,6 @@ export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
export { LLink } from './LLink'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'

View File

@@ -272,7 +272,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match
if (matchingIndex === undefined) {
if (matchingIndex === -1) {
console.debug(
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
this
@@ -331,7 +331,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* Used when bypassing nodes.
* @param slot The output slot index on this node
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `undefined`.
* @returns The index of the input slot on this node, otherwise `-1`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
@@ -352,15 +352,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return slot
}
// Preserve legacy behaviour; use exact match first.
const exactMatch = inputs.findIndex((input) => input.type === type)
if (exactMatch !== -1) return exactMatch
// Find first matching slot - prefer exact type
return (
// Preserve legacy behaviour; use exact match first.
inputs.findIndex((input) => input.type === type) ??
inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
return inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
}

View File

@@ -313,9 +313,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
widget: Readonly<IBaseWidget>
) {
// Use the first matching widget
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
const targetWidget = toConcreteWidget(widget, this)
const promotedWidget = targetWidget.createCopyForNode(this)
Object.assign(promotedWidget, {
get name() {
@@ -370,7 +369,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphNode: this
})
input.widget = { name: subgraphInput.name }
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
const backingInput =
targetWidget.node.findInputSlot(widget.name, true)?.widget ?? {}
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
Object.setPrototypeOf(input.widget, backingInput)
input._widget = promotedWidget
}

View File

@@ -76,6 +76,7 @@ export type IWidget =
| IImageCompareWidget
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -224,6 +225,12 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'
value: string
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -0,0 +1,41 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IAssetWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget, type DrawWidgetOptions } from './BaseWidget'
export class AssetWidget
extends BaseWidget<IAssetWidget>
implements IAssetWidget
{
constructor(widget: IAssetWidget, node: LGraphNode) {
super(widget, node)
this.type ??= 'asset'
this.value = widget.value?.toString() ?? ''
}
override get _displayValue(): string {
return String(this.value) //FIXME: Resolve asset name
}
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, { width, showText })
if (showText) {
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
// Restore original context attributes
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
override onClick() {
//Open Modal
this.callback?.(this.value)
}
}

View File

@@ -7,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { toClass } from '@/lib/litegraph/src/utils/type'
import { AssetWidget } from './AssetWidget'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { ButtonWidget } from './ButtonWidget'
@@ -47,6 +48,7 @@ export type WidgetTypeMap = {
imagecompare: ImageCompareWidget
selectbutton: SelectButtonWidget
textarea: TextareaWidget
asset: AssetWidget
[key: string]: BaseWidget
}
@@ -115,6 +117,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(SelectButtonWidget, narrowedWidget, node)
case 'textarea':
return toClass(TextareaWidget, narrowedWidget, node)
case 'asset':
return toClass(AssetWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -7,10 +7,10 @@ import type {
ISerialisedGraph,
SerialisableGraph
} from '../src/types/serialisation'
import floatingBranch from './assets/floatingBranch.json'
import floatingLink from './assets/floatingLink.json'
import linkedNodes from './assets/linkedNodes.json'
import reroutesComplex from './assets/reroutesComplex.json'
import floatingBranch from './assets/floatingBranch.json' with { type: 'json' }
import floatingLink from './assets/floatingLink.json' with { type: 'json' }
import linkedNodes from './assets/linkedNodes.json' with { type: 'json' }
import reroutesComplex from './assets/reroutesComplex.json' with { type: 'json' }
import {
basicSerialisableGraph,
minimalSerialisableGraph,

View File

@@ -193,6 +193,8 @@
"updateSelected": "Update Selected",
"updateAll": "Update All",
"updatingAllPacks": "Updating all packages",
"disabledNodesWontUpdate": "Disabled nodes will not be updated",
"enablePackToChangeVersion": "Enable this pack to change versions",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
@@ -211,6 +213,7 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
"notAvailable": "Not Available",
@@ -1470,6 +1473,8 @@
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"missingNodesTitle": "Some Nodes Are Missing",
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type cant be found.",
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
@@ -1757,6 +1762,9 @@
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"widgets": {
"selectModel": "Select model"
},
"nodeHelpPage": {
"inputs": "Inputs",
"outputs": "Outputs",

View File

@@ -3,7 +3,7 @@
"label": "업데이트 확인"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "사용자 정의 노드 폴더 열기"
"label": "커스텀 노드 폴더 열기"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "입력 폴더 열기"
@@ -129,7 +129,7 @@
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 종료"
"label": "서브그래프 나가기"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
@@ -138,7 +138,7 @@
"label": "선택한 노드 그룹화"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "선택한 서브그래프 풀기"
"label": "선택한 서브그래프 묶음 풀기"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "선택한 노드를 그룹 노드로 변환"
@@ -171,13 +171,13 @@
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "사용자 정의 노드 (베타)"
"label": "커스텀 노드 (베타)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "커스텀 노드 (레거시)"
"label": "커스텀 노드 (구버전)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "매니저 메뉴 (레거시)"
"label": "매니저 메뉴 (구버전)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "누락된 팩 설치"

View File

@@ -287,7 +287,7 @@
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "명령 {command} 금지되었습니다. 자세한 정보는 관리자에게 문의하십시오.",
"commandProhibited": "{command} 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
"community": "커뮤니티",
"completed": "완료됨",
"confirm": "확인",
@@ -326,7 +326,7 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상이 필요합니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전이 필요합니다.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
@@ -353,7 +353,7 @@
"micPermissionDenied": "마이크 권한이 거부되었습니다",
"migrate": "이전(migrate)",
"missing": "누락됨",
"moreWorkflows": "더 많은 워크플로",
"moreWorkflows": "더 많은 워크플로",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
@@ -464,8 +464,8 @@
"appPathLocationTooltip": "ComfyUI의 앱 에셋 디렉토리. ComfyUI 코드 및 에셋을 저장합니다.",
"cannotWrite": "선택한 경로에 쓸 수 없습니다",
"chooseInstallationLocation": "설치 위치 선택",
"customNodes": "사용자 정의 노드",
"customNodesDescription": "기존 ComfyUI 설치에서 사용자 정의 노드를 다시 설치합니다.",
"customNodes": "커스텀 노드",
"customNodesDescription": "기존 ComfyUI 설치에서 커스텀 노드를 다시 설치합니다.",
"desktopAppSettings": "데스크탑 앱 설정",
"desktopAppSettingsDescription": "ComfyUI가 데스크탑에서 어떻게 작동하는지 구성하세요. 이 설정은 나중에 변경할 수 있습니다.",
"desktopSettings": "데스크탑 설정",
@@ -489,7 +489,7 @@
"helpImprove": "ComfyUI 개선에 도움을 주세요",
"installLocation": "설치 위치",
"installLocationDescription": "ComfyUI의 사용자 데이터 디렉토리를 선택하십시오. 선택한 위치에 Python 환경이 설치됩니다. 선택한 디스크에 충분한 공간(~15GB)이 남아 있는지 확인하십시오.",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 사용자 정의 노드\n",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 커스텀 노드\n",
"insufficientFreeSpace": "공간이 부족합니다 - 최소한의 여유 공간",
"isOneDrive": "OneDrive에 설치하면 문제가 발생할 수 있습니다. OneDrive가 아닌 위치에 설치하는 것을 강력히 권장합니다.",
"manualConfiguration": {
@@ -633,9 +633,9 @@
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"legacyManagerUI": "레거시 UI 사용",
"legacyManagerUIDescription": "레거시 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 레거시 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"legacyManagerUI": "구버전 매니저 UI 사용",
"legacyManagerUIDescription": "구버전 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 구버전 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "최신 테스트 버전(nightly)",
@@ -663,7 +663,7 @@
"pending": "대기 중",
"unknown": "알 수 없음"
},
"title": "사용자 정의 노드 관리자",
"title": "커스텀 노드 관리자",
"totalNodes": "총 노드",
"tryAgainLater": "나중에 다시 시도해 주세요.",
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
@@ -750,8 +750,8 @@
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes (Legacy)": "커스텀 노드(레거시)",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Custom Nodes (Legacy)": "커스텀 노드(구버전)",
"Custom Nodes Manager": "커스텀 노드 관리자",
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
@@ -770,7 +770,7 @@
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Manager": "매니저",
"Manager Menu (Legacy)": "매니저 메뉴(레거시)",
"Manager Menu (Legacy)": "매니저 메뉴(구버전)",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
@@ -779,7 +779,7 @@
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
"Open": "열기",
"Open Custom Nodes Folder": "사용자 정의 노드 폴더 열기",
"Open Custom Nodes Folder": "커스텀 노드 폴더 열기",
"Open DevTools": "개발자 도구 열기",
"Open Inputs Folder": "입력 폴더 열기",
"Open Logs Folder": "로그 폴더 열기",
@@ -814,13 +814,13 @@
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
@@ -828,10 +828,10 @@
"Unload Models": "모델 언로드",
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Unlock Canvas": "캔버스 잠금 해제",
"Unpack the selected Subgraph": "선택한 서브그래프 풀기",
"Unpack the selected Subgraph": "선택한 서브그래프 묶음 풀기",
"View": "보기",
"Workflow": "워크플로",
"Workflows": "워크플로",
"Workflows": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소",
"Zoom to fit": "화면에 맞추기"
@@ -842,10 +842,10 @@
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시",
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
"sideToolbar_queue": "sideToolbar.대기열",
"sideToolbar_workflows": "sideToolbar.워크플로"
"sideToolbar_modelLibrary": "사이드툴바.모델 라이브러리",
"sideToolbar_nodeLibrary": "사이드툴바.노드 라이브러리",
"sideToolbar_queue": "사이드툴바.대기열",
"sideToolbar_workflows": "사이드툴바.워크플로"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
@@ -1011,7 +1011,7 @@
"name": "DirectML 장치 번호"
},
"disable-all-custom-nodes": {
"name": "모든 사용자 정의 노드 로드 비활성화."
"name": "모든 커스텀 노드 로드 비활성화."
},
"disable-ipex-optimize": {
"name": "IPEX 최적화 비활성화"
@@ -1247,7 +1247,7 @@
"Basics": "기본",
"ComfyUI Examples": "ComfyUI 예시",
"ControlNet": "컨트롤넷",
"Custom Nodes": "사용자 정의 노드",
"Custom Nodes": "커스텀 노드",
"Flux": "FLUX",
"Image": "이미지",
"Image API": "이미지 API",
@@ -1636,7 +1636,7 @@
"versionMismatchWarning": {
"dismiss": "닫기",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상 버전 필요합니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전 필요합니다.",
"title": "버전 호환성 경고",
"updateFrontend": "프론트엔드 업데이트"
},

View File

@@ -9,9 +9,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
Point,
ReadOnlyPoint
Point
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
@@ -79,21 +77,6 @@ export function calculateInputSlotPosFromSlot(
const { pos } = input
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
if (isWidgetInputSlot(input)) {
// Widget slot - pass the slot object
return calculateVueSlotPosition(context, true, input, -1)
} else {
// Regular slot - find its index in default vertical inputs
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, true, input, slotIndex)
}
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
@@ -131,15 +114,6 @@ export function calculateOutputSlotPos(
const outputPos = outputSlot.pos
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
@@ -163,7 +137,7 @@ export function getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
): Point {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)
@@ -195,8 +169,23 @@ export function getSlotPosition(
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
// Fallback: calculate directly from node properties if layout not available
const context: SlotPositionContext = {
nodeX: node.pos[0],
nodeY: node.pos[1],
nodeWidth: node.size[0],
nodeHeight: node.size[1],
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
/**
@@ -218,66 +207,3 @@ function getDefaultVerticalOutputs(
): INodeOutputSlot[] {
return context.outputs.filter((slot) => !slot.pos)
}
/**
* Calculate slot position using Vue node dimensions.
* This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
* @param context Node context
* @param isInput Whether this is an input slot (true) or output slot (false)
* @param slot The slot object (for widget detection)
* @param slotIndex The index of the slot in the appropriate array
* @returns The [x, y] position of the slot center in graph coordinates
*/
function calculateVueSlotPosition(
context: SlotPositionContext,
isInput: boolean,
slot: INodeSlot,
slotIndex: number
): Point {
const { nodeX, nodeY, nodeWidth, widgets } = context
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
let slotCenterY: number
// IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
// The header is rendered ABOVE this position at negative Y coordinates
// So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
const headerDifference =
dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
// Widget input slot - calculate based on widget position
// Count regular (non-widget) input slots
const regularInputCount = getDefaultVerticalInputs(context).length
// Find widget index
const widgetIndex =
widgets?.findIndex(
(w) => w.name === (slot as INodeInputSlot).widget?.name
) ?? 0
// Y position relative to the node body top (not the header)
slotCenterY =
headerDifference +
regularInputCount * dimensions.SLOT_HEIGHT +
(regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
widgetIndex *
(dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
dimensions.STANDARD_WIDGET_HEIGHT / 2
} else {
// Regular slot (input or output)
// Slots start at the top of the body, but we need to account for Vue's larger header
slotCenterY =
headerDifference +
slotIndex * dimensions.SLOT_HEIGHT +
dimensions.SLOT_HEIGHT / 2
}
// Calculate X position
// Input slots: 10px from left edge (center of 20x20 connector)
// Output slots: 10px from right edge (center of 20x20 connector)
const slotCenterX = isInput ? 10 : nodeWidth - 10
return [nodeX + slotCenterX, nodeY + slotCenterY]
}

View File

@@ -19,6 +19,7 @@ import type {
LayoutOperation,
MoveNodeOperation,
MoveRerouteOperation,
NodeBoundsUpdate,
ResizeNodeOperation,
SetNodeZIndexOperation
} from '@/renderer/core/layout/types'
@@ -35,6 +36,7 @@ import {
type Point,
type RerouteId,
type RerouteLayout,
type Size,
type SlotLayout
} from '@/renderer/core/layout/types'
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
@@ -49,7 +51,62 @@ const logger = log.getLogger('LayoutStore')
// Constants
const REROUTE_RADIUS = 8
// Utility functions
function asRerouteId(id: string | number): RerouteId {
return Number(id)
}
function asLinkId(id: string | number): LinkId {
return Number(id)
}
interface NodeLayoutData {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
bounds: Bounds
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
interface RerouteData {
id: RerouteId
position: Point
parentId: LinkId
linkIds: LinkId[]
}
// Generic typed Y.Map interface
interface TypedYMap<T> {
get<K extends keyof T>(key: K): T[K] | undefined
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
}
class LayoutStoreImpl implements LayoutStore {
private static readonly NODE_DEFAULTS: NodeLayoutData = {
id: 'unknown-node',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
private static readonly REROUTE_DEFAULTS: RerouteData = {
id: 0,
position: { x: 0, y: 0 },
parentId: 0,
linkIds: []
}
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
@@ -127,6 +184,34 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getNodeField<K extends keyof NodeLayoutData>(
ynode: Y.Map<unknown>,
field: K,
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
): NodeLayoutData[K] {
const typedNode = ynode as TypedYMap<NodeLayoutData>
const value = typedNode.get(field)
return value ?? defaultValue
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
): LinkData[K] | undefined {
const typedLink = ylink as TypedYMap<LinkData>
return typedLink.get(field)
}
private getRerouteField<K extends keyof RerouteData>(
yreroute: Y.Map<unknown>,
field: K,
defaultValue: RerouteData[K] = LayoutStoreImpl.REROUTE_DEFAULTS[field]
): RerouteData[K] {
const typedReroute = yreroute as TypedYMap<RerouteData>
const value = typedReroute.get(field)
return value ?? defaultValue
}
/**
* Get or create a customRef for a node layout
*/
@@ -678,7 +763,7 @@ class LayoutStoreImpl implements LayoutStore {
// Check precise distance for candidates
for (const rerouteKey of candidateRerouteKeys) {
const rerouteId = Number(rerouteKey) as RerouteId // Convert string key back to numeric
const rerouteId = asRerouteId(rerouteKey)
const rerouteLayout = this.rerouteLayouts.get(rerouteId)
if (rerouteLayout) {
const dx = point.x - rerouteLayout.position.x
@@ -723,7 +808,7 @@ class LayoutStoreImpl implements LayoutStore {
slots: this.slotSpatialIndex.query(bounds),
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => Number(key) as RerouteId) // Convert string keys to numeric
.map((key) => asRerouteId(key))
}
}
@@ -902,7 +987,7 @@ class LayoutStoreImpl implements LayoutStore {
return
}
const size = ynode.get('size') as { width: number; height: number }
const size = this.getNodeField(ynode, 'size')
const newBounds = {
x: operation.position.x,
y: operation.position.y,
@@ -931,7 +1016,7 @@ class LayoutStoreImpl implements LayoutStore {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = ynode.get('position') as Point
const position = this.getNodeField(ynode, 'position')
const newBounds = {
x: position.x,
y: position.y,
@@ -1120,9 +1205,9 @@ class LayoutStoreImpl implements LayoutStore {
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
const connectedLinks: LinkId[] = []
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
const linkId = Number(linkIdStr) as LinkId
const sourceNodeId = linkData.get('sourceNodeId') as NodeId
const targetNodeId = linkData.get('targetNodeId') as NodeId
const linkId = asLinkId(linkIdStr)
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
connectedLinks.push(linkId)
@@ -1136,7 +1221,7 @@ class LayoutStoreImpl implements LayoutStore {
*/
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
if (change.action === 'delete') {
const linkId = Number(linkIdStr) as LinkId
const linkId = asLinkId(linkIdStr)
this.cleanupLinkData(linkId)
}
// Link was added or updated - geometry will be computed separately
@@ -1175,7 +1260,7 @@ class LayoutStoreImpl implements LayoutStore {
change: YEventChange,
rerouteIdStr: string
): void {
const rerouteId = Number(rerouteIdStr) as RerouteId
const rerouteId = asRerouteId(rerouteIdStr)
if (change.action === 'delete') {
this.handleRerouteDelete(rerouteId)
@@ -1199,7 +1284,7 @@ class LayoutStoreImpl implements LayoutStore {
const rerouteData = this.yreroutes.get(String(rerouteId))
if (!rerouteData) return
const position = rerouteData.get('position') as Point
const position = this.getRerouteField(rerouteData, 'position')
if (!position) return
const layout = this.createRerouteLayout(rerouteId, position)
@@ -1263,12 +1348,12 @@ class LayoutStoreImpl implements LayoutStore {
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: ynode.get('id') as string,
position: ynode.get('position') as Point,
size: ynode.get('size') as { width: number; height: number },
zIndex: ynode.get('zIndex') as number,
visible: ynode.get('visible') as boolean,
bounds: ynode.get('bounds') as Bounds
id: this.getNodeField(ynode, 'id'),
position: this.getNodeField(ynode, 'position'),
size: this.getNodeField(ynode, 'size'),
zIndex: this.getNodeField(ynode, 'zIndex'),
visible: this.getNodeField(ynode, 'visible'),
bounds: this.getNodeField(ynode, 'bounds')
}
}
@@ -1341,6 +1426,31 @@ class LayoutStoreImpl implements LayoutStore {
getStateAsUpdate(): Uint8Array {
return Y.encodeStateAsUpdate(this.ydoc)
}
/**
* Batch update node bounds using Yjs transaction for atomicity.
*/
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
if (updates.length === 0) return
// Set source to Vue for these DOM-driven updates
const originalSource = this.currentSource
this.currentSource = LayoutSource.Vue
this.ydoc.transact(() => {
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds)
ynode.set('size', { width: bounds.width, height: bounds.height })
}
}, this.currentActor)
// Restore original source
this.currentSource = originalSource
}
}
// Create singleton instance

View File

@@ -31,6 +31,11 @@ export interface Bounds {
height: number
}
export interface NodeBoundsUpdate {
nodeId: NodeId
bounds: Bounds
}
export type NodeId = string
export type LinkId = number
export type RerouteId = number
@@ -320,4 +325,9 @@ export interface LayoutStore {
setActor(actor: string): void
getCurrentSource(): LayoutSource
getCurrentActor(): string
// Batch updates
batchUpdateNodeBounds(
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void
}

View File

@@ -74,6 +74,10 @@ export const useTransformState = () => {
// Computed transform string for CSS
const transformStyle = computed(() => ({
// Match LiteGraph DragAndScale.toCanvasContext():
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
@@ -103,15 +107,15 @@ export const useTransformState = () => {
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = canvas * scale + offset
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
y: point.y * camera.z + camera.y
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
@@ -121,15 +125,15 @@ export const useTransformState = () => {
* Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space.
*
* Formula: canvas = (screen - offset) / scale
* Formula: canvas = screen / scale - offset
*
* @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system
*/
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
y: (point.y - camera.y) / camera.z
x: point.x / camera.z - camera.x,
y: point.y / camera.z - camera.y
}
}

View File

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg"
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
@@ -10,9 +10,6 @@
'lg-slot--dot-only': dotOnly,
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -43,11 +40,7 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import {
COMFY_VUE_NODE_DIMENSIONS,
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
@@ -82,9 +75,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined

View File

@@ -7,28 +7,34 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-[#15161A]',
'bg-white dark-theme:bg-charcoal-100',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2',
'lg-node absolute rounded-2xl',
// border
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
!!executing && 'border-blue-500 dark-theme:border-blue-500',
!!error && 'border-red-700 dark-theme:border-red-300',
// hover
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
// Selected
'outline-transparent -outline-offset-2 outline-2',
!!isSelected && 'outline-black dark-theme:outline-white',
!!(isSelected && executing) &&
'outline-blue-500 dark-theme:outline-blue-500',
!!(isSelected && error) && 'outline-red-500 dark-theme:outline-red-500',
{
'outline-black dark-theme:outline-white': isSelected
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
'will-change-transform': isDragging
},
lodCssClass
lodCssClass,
'pointer-events-auto'
)
"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
pointerEvents: 'auto'
zIndex: zIndex
},
dragStyle
]"
@@ -53,8 +59,35 @@
/>
</div>
<div
v-if="
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
:class="
cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isMinimalLOD && !isCollapsed">
<div :class="cn(separatorClasses, 'mb-4')" />
<div class="mb-4 relative">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
@@ -99,20 +132,12 @@
/>
</div>
</template>
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
:style="{ width: `${progress * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -121,6 +146,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
@@ -142,19 +168,31 @@ interface LGraphNodeProps {
const props = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [event: PointerEvent, nodeData: VueNodeData]
'node-click': [
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
]
'slot-click': [
event: PointerEvent,
nodeData: VueNodeData,
slotIndex: number,
isInput: boolean
]
dragStart: [event: DragEvent, nodeData: VueNodeData]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
useVueElementTracking(props.nodeData.id, 'node')
// Inject selection state from parent
const selectedNodeIds = inject(SelectedNodeIdsKey, ref(new Set<string>()))
const selectedNodeIds = inject(SelectedNodeIdsKey)
if (!selectedNodeIds) {
throw new Error(
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
)
}
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
@@ -187,6 +225,7 @@ onErrorCaptured((error) => {
// Use layout system for node position and dragging
const {
position: layoutPosition,
zIndex,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
@@ -197,6 +236,10 @@ const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const lastY = ref(0)
const lastX = ref(0)
// Treat tiny pointer jitter as a click, not a drag
const DRAG_THRESHOLD_PX = 4
// Track collapsed state
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
@@ -219,7 +262,9 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
@@ -240,9 +285,8 @@ const handlePointerDown = (event: PointerEvent) => {
// Start drag using layout system
isDragging.value = true
startDrag(event)
// Emit node-click for selection handling in GraphCanvas
emit('node-click', event, props.nodeData)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
@@ -256,6 +300,11 @@ const handlePointerUp = (event: PointerEvent) => {
isDragging.value = false
void endDrag(event)
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
emit('node-click', event, props.nodeData, wasDragging)
}
const handleCollapse = () => {

View File

@@ -0,0 +1,195 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'
import { type PropType, defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import NodeSlots from './NodeSlots.vue'
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
type: 'TestType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
type?: string
boundingRect?: [number, number, number, number]
}
const InputSlotStub = defineComponent({
name: 'InputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-input-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const OutputSlotStub = defineComponent({
name: 'OutputSlot',
props: {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
},
template: `
<div
class="stub-output-slot"
:data-index="index"
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
})
const mountSlots = (nodeData: VueNodeData, readonly = false) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(NodeSlots, {
global: {
plugins: [i18n, createPinia()],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData, readonly }
})
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
// Two inputs without widgets (object and string) and one with widget (filtered)
const inputObjNoWidget = {
name: 'objNoWidget',
type: 'number',
boundingRect: [0, 0, 0, 0]
}
const inputObjWithWidget = {
name: 'objWithWidget',
type: 'number',
boundingRect: [0, 0, 0, 0],
widget: { name: 'objWithWidget' }
}
const inputs = [inputObjNoWidget, inputObjWithWidget, 'stringInput']
const wrapper = mountSlots(makeNodeData({ inputs }))
const inputEls = wrapper
.findAll('.stub-input-slot')
.map((w) => w.element as HTMLElement)
// Should filter out the widget-backed input; expect 2 inputs rendered
expect(inputEls.length).toBe(2)
// Verify expected tuple of {index, name, nodeId}
const info = inputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(info).toEqual([
{
index: 0,
name: 'objNoWidget',
nodeId: '123',
type: 'number',
readonly: false
},
// string input is converted to object with default type 'any'
{
index: 1,
name: 'stringInput',
nodeId: '123',
type: 'any',
readonly: false
}
])
// Ensure widget-backed input was indeed filtered out
expect(wrapper.find('[data-name="objWithWidget"]').exists()).toBe(false)
})
it('maps outputs and passes correct indexes', () => {
const outputObj = { name: 'outA', type: 'any', boundingRect: [0, 0, 0, 0] }
const outputs = [outputObj, 'outB']
const wrapper = mountSlots(makeNodeData({ outputs }))
const outputEls = wrapper
.findAll('.stub-output-slot')
.map((w) => w.element as HTMLElement)
expect(outputEls.length).toBe(2)
const outInfo = outputEls.map((el) => ({
index: Number(el.dataset.index),
name: el.dataset.name ?? '',
nodeId: el.dataset.nodeId ?? '',
type: el.dataset.type ?? '',
readonly: el.dataset.readonly === 'true'
}))
expect(outInfo).toEqual([
{ index: 0, name: 'outA', nodeId: '123', type: 'any', readonly: false },
// string output mapped to object with type 'any'
{ index: 1, name: 'outB', nodeId: '123', type: 'any', readonly: false }
])
})
it('renders nothing when there are no inputs/outputs', () => {
const wrapper = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(wrapper.findAll('.stub-input-slot').length).toBe(0)
expect(wrapper.findAll('.stub-output-slot').length).toBe(0)
})
it('passes readonly to child slots', () => {
const wrapper = mountSlots(
makeNodeData({ inputs: ['a'], outputs: ['b'] }),
/* readonly */ true
)
const all = [
...wrapper
.findAll('.stub-input-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement),
...wrapper
.findAll('.stub-output-slot')
.filter((w) => w.element instanceof HTMLElement)
.map((w) => w.element as HTMLElement)
]
expect(all.length).toBe(2)
for (const el of all) {
expect.soft(el.dataset.readonly).toBe('true')
}
})
})

View File

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg"
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
@@ -11,9 +11,6 @@
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
'justify-center': dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Slot Name -->
<span
@@ -45,7 +42,6 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
@@ -81,9 +77,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined

View File

@@ -11,8 +11,7 @@
import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useCanvasStore } from '@/stores/graphStore'
interface NodeManager {
@@ -21,13 +20,17 @@ interface NodeManager {
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { bringNodeToFront } = useNodeZIndex()
/**
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
*/
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
const handleNodeSelect = (
event: PointerEvent,
nodeData: VueNodeData,
wasDragging: boolean
) => {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -43,16 +46,18 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
canvasStore.canvas.select(node)
}
} else {
// If it wasn't a drag: single-select the node
if (!wasDragging) {
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Regular click -> single select
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) {
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
bringNodeToFront(nodeData.id)
}
// Update canvas selection tracking
@@ -109,7 +114,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
handleNodeSelect(event, nodeData, false)
}
}
@@ -128,7 +133,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
// Select the node if not already selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
handleNodeSelect(event, nodeData, false)
}
// Let LiteGraph handle the context menu
@@ -153,7 +158,7 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData)
handleNodeSelect(syntheticEvent, nodeData, false)
}
// Set drag data for potential drop operations
@@ -171,14 +176,13 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAllNodes()
canvasStore.canvas.deselectAll()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.selectNode(node)
node.selected = true
canvasStore.canvas.select(node)
}
})

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