Compare commits

..

47 Commits

Author SHA1 Message Date
snomiao
ce71c2c529 feat: Add more Storybook stories for UI components 2025-09-22 21:10:30 +00:00
Alexander Brown
e5d4d07d32 Refactor: More state management simplification (#5721)
## Summary

Remove more procedural synchronization in favor of using reactive
references.

> Note: Also includes some fixes for issues caused during HMR.

## Review Focus

In testing it seems to work the same, but let me know if I missed
something.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5721-Refactor-More-state-management-simplification-2766d73d3650819b8d7ddc047c460f2b)
by [Unito](https://www.unito.io)
2025-09-22 13:15:33 -07:00
Alexander Piskun
f086377307 add pricing for new api nodes (#5724)
## Summary

Added prices for the new upcoming API nodes. Backport required.
2025-09-22 11:33:00 -07:00
filtered
687b9e659c Fix reroute ID 0 treated as invalid (#5723)
## Summary

Fixes old logic bug from refactor
https://github.com/Comfy-Org/litegraph.js/pull/602/files

## Changes

- Fixes truthy refactor to explicitly check undefined

## Review Focus

No expectation that this will impact prod, however it may impact
extensions IF someone has explicitly been setting link parentId to 0.
This would be very strange, as it would cause unexpected behaviour in
other parts of the code (which all explicitly check `undefined`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5723-Fix-reroute-ID-0-treated-as-invalid-2766d73d365081568124ce1f85cdf84e)
by [Unito](https://www.unito.io)
2025-09-22 11:13:38 -07:00
Christian Byrne
da0d51311b fix Vue node being dragged when interacting with widgets (e.g., resizing textarea) (#5719)
## Summary

Applying changes in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5516 to entire widget
wrapper.
 
## Changes

- **What**: Added `.stop` modifier to pointer events in NodeWidgets
component to prevent [event
propagation](https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation)

## Review Focus

Verify widget interactions remain functional while ensuring parent node
drag/selection behavior is properly isolated.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5719-fix-Vue-node-being-dragged-when-interacting-with-widgets-e-g-resizing-textarea-2766d73d3650815091adcd1d65197c7b)
by [Unito](https://www.unito.io)
2025-09-21 21:56:03 -07:00
Christian Byrne
e314d9cbd9 [refactor] Simplify current user resolved hook implementation (#5718)
## Summary

Refactored `onUserResolved` function in auth composable to use VueUse
`whenever` utility instead of manual watch implementation and use
`immediate` option instead of invoking manually before creating watcher.

## Changes

- **What**: Replaced manual watch + immediate check pattern with [VueUse
whenever](https://vueuse.org/shared/whenever/) utility in
`useCurrentUser.ts:37`

## Review Focus

Behavioral equivalence verification - `whenever` with `immediate: true`
should maintain identical callback timing and cleanup semantics.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5718-refactor-Simplify-current-user-resolved-hook-implementation-2766d73d365081008b6de156dd78f940)
by [Unito](https://www.unito.io)
2025-09-21 21:53:25 -07:00
Christian Byrne
95baf8d2f1 [style] update Vue node tooltip style (#5717)
## Summary

Change Vue node tooltips to align with
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Nodes?node-id=6267-16837&m=dev)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5717-style-update-Vue-node-tooltip-style-2766d73d365081bdb095faef17f6aeb6)
by [Unito](https://www.unito.io)
2025-09-21 20:01:33 -07:00
Christian Byrne
f951e07cea fix bypass hotkey in vue nodes and fix node data instrumentation setup issue when switching to Vue nodes after initial load (#5715)
## Summary

Fixed Vue node keybinding target element ID to enable
bypass/pin/collapse hotkeys in both LiteGraph and Vue rendering modes.

Also fixed a bug when starting in litegraph mode => switching to Vue
nodes without reloading => `graph.onTrigger` is set to `undefined` which
interferes with proper setup of node data instrumentation, among other
things.

## Changes

- **What**: Updated keybinding `targetElementId` from `graph-canvas` to
`graph-canvas-container` for node manipulation commands (parent of both
the canvas and transform pane -- vue nodes container).
- **What**: Added conditional `onTrigger` handler restoration in slot
layout sync to prevent Vue node manager conflicts

## Review Focus

Event handler precedence between Vue nodes and LiteGraph systems during
mode switching, ensuring hotkeys work consistently across rendering
modes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5715-fix-bypass-hotkey-in-vue-nodes-and-fix-node-data-instrumentation-setup-issue-when-switchi-2756d73d3650815c8ec8d5e4d06232e3)
by [Unito](https://www.unito.io)
2025-09-21 17:32:12 -07:00
Christian Byrne
023e466dba fix using shift modifier to (de-)select Vue nodes (#5714)
## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5688 by
adding shift modifier support for multi-selecting Vue nodes, enabling
standard shift+click selection behavior alongside existing
ctrl/cmd+click.

## Changes

- **What**: Updated Vue node event handlers to include `event.shiftKey`
in multi-select logic
- **Testing**: Added browser tests for both ctrl and shift modifier
selection behaviors

## Review Focus

Multi-select behavior consistency across different input modifiers and
platform compatibility (Windows/Mac/Linux shift key handling).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5714-fix-using-shift-modifier-to-de-select-Vue-nodes-2756d73d365081bcb5e0fe80eacdb2f0)
by [Unito](https://www.unito.io)
2025-09-21 14:39:40 -07:00
Christian Byrne
abd6823744 [refactor] Remove redundant module comment (#5711)
Removes a comment added in initial Vue Nodes commit. The comment is
interpolated between import statements which is stylistically awkward
and it is almost totally redundant with the doc comment on the
composable:


c1d4709e96/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts (L10-L14)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5711-refactor-Remove-redundant-module-comment-2756d73d365081ef9bffe0257b3670f1)
by [Unito](https://www.unito.io)
2025-09-21 14:30:58 -07:00
Alexander Brown
c4c0e52e64 Refactor: Let LGraphNode handle more events itself (#5709)
## Summary

Don't route events up through GraphCanvas if the component itself can
handle the changes

## Changes

- **What**: Reduce the indirect access or action dispatch to
composables/stores.

## Review Focus

The behavior should be either equivalent or a little snappier than
before. Also, the local state in LGraphNode has (almost) all been
removed in favor of reacting to the nodeData prop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5709-Refactor-Let-LGraphNode-handle-more-events-itself-2756d73d365081e6a88ce6241bceecc0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-20 22:14:30 -07:00
Christian Byrne
295332dc46 update CODEOWNERS (#5667)
Add explicit CODEOWNERS for new features to allow more domain-driven
review/approval/ownership processes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5667-update-CODEOWNERS-2736d73d3650817ea52be9c4a8fe5ff2)
by [Unito](https://www.unito.io)
2025-09-20 20:11:35 -07:00
Christian Byrne
5c498348b8 fix: update to standardized mobile web app meta tag syntax (#5672)
## Summary

Fixed WebKit deprecation warning by updating to standardized mobile web
app meta tag syntax.

## Changes

- **What**: Replaced deprecated `apple-mobile-web-app-capable` with
cross-platform
[`mobile-web-app-capable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#mobile-web-app-capable)
meta tag to align with WebKit's move toward vendor-neutral standards

## Review Focus

Verify "Add to Home Screen" functionality still works on iOS/iPadOS and
that the WebKit console warning is resolved in production builds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5672-fix-update-to-standardized-mobile-web-app-meta-tag-syntax-2736d73d3650811cb2a1f0b14ce0a0e7)
by [Unito](https://www.unito.io)
2025-09-20 20:10:51 -07:00
Alexander Brown
8133bd4b7b Refactor: Composable disentangling (#5695)
## Summary

Prerequisite refactor/cleanup to use a global store instead of having
nodes throw up events to a parent component that stores a reference to a
singleton service that itself bootstraps and synchronizes with a
separate service to maintain a partially reactive but not fully reactive
set of states that describe some but not all aspects of the nodes on
either the litegraph, the vue side, or both.

## Changes

- **What**: Refactoring, the behavior should not change.
- **Dependencies**: A type utility to help with Vue component props

## Review Focus

Is there something about the current structure that this could affect
that would not be caught by our tests or using the application?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5695-Refactor-Composable-disentangling-2746d73d365081e6938ce656932f3e36)
by [Unito](https://www.unito.io)
2025-09-20 13:06:42 -07:00
Arjan Singh
fd12591756 [feat] integrate asset browser with widget system (#5629)
## Summary

Add asset browser dialog integration for combo widgets with full
animation support and proper state management.

(Thank you Claude from saving me me from merge conflict hell on this
one.)

## Changes

- Widget integration: combo widgets now use AssetBrowserModal for
eligible asset types
- Dialog animations: added animateHide() for smooth close transitions
- Async operations: proper sequencing of widget updates and dialog
animations
- Service layer: added getAssetsForNodeType() and getAssetDetails()
methods
- Type safety: comprehensive TypeScript types and error handling
- Test coverage: unit tests for all new functionality
- Bonus: fixed the hardcoded labels in AssetFilterBar

Widget behavior:
- Shows asset browser button for eligible widgets when asset API enabled
- Handles asset selection with proper callback sequencing
- Maintains widget value updates and litegraph notification

## Review Focus

I will call out some stuff inline.

## Screenshots


https://github.com/user-attachments/assets/9d3a72cf-d2b0-445f-8022-4c49daa04637

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5629-feat-integrate-asset-browser-with-widget-system-2726d73d365081a9a98be9a2307aee0b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-20 11:44:18 -07:00
Christian Byrne
b3c939ff15 fix: add Safari requestIdleCallback polyfill (#5664)
## Summary

Implemented cross-browser requestIdleCallback polyfill to fix Safari
crashes during graph initialization.

## Changes

- **What**: Added
[requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
polyfill following [VS Code's
pattern](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
with setTimeout fallback for Safari
- **Breaking**: None - maintains existing GraphView behavior

## Review Focus

Safari compatibility testing and timeout handling in the 15ms fallback
window. Verify that initialization tasks (keybindings, server config,
model loading) still execute properly on Safari iOS.

## References

- [VS Code async.ts
implementation](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
- Source pattern for our polyfill
- [MDN
requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
- Browser API documentation
- [Safari requestIdleCallback
support](https://caniuse.com/requestidlecallback) - Browser
compatibility table

Fixes CLOUD-FRONTEND-STAGING-N

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5664-fix-add-Safari-requestIdleCallback-polyfill-2736d73d365081cdbcf1fb816fe098d6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 23:34:15 -07:00
Christian Byrne
0801778f60 feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572)
## Summary
- Adds subgraph title button to Vue node headers (matching LiteGraph
behavior)
- Fixes Vue node lifecycle issues during subgraph navigation and tab
switching
- Extracts reusable `useSubgraphNavigation` composable with
callback-based API
- Adds comprehensive tests for subgraph functionality
- Ensures proper graph context restoration during tab switches



https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 14:19:06 -07:00
Johnpaul Chiwetelu
8ffe63f54e Layoutstore Minimap calculation (#5547)
This pull request refactors the minimap rendering system to use a
unified, extensible data source abstraction for all minimap operations.
By introducing a data source interface and factory, the minimap can now
seamlessly support multiple sources of node layout (such as the
`LayoutStore` or the underlying `LiteGraph`), improving maintainability
and future extensibility. Rendering logic and change detection
throughout the minimap have been updated to use this new abstraction,
resulting in cleaner code and easier support for new data models.

**Core architecture improvements:**

* Introduced a new `IMinimapDataSource` interface and related data types
(`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to
standardize node, link, and group data for minimap rendering.
* Added an abstract base class `AbstractMinimapDataSource` that provides
shared logic for bounds and group/link extraction, and implemented two
concrete data sources: `LiteGraphDataSource` (for classic graph data)
and `LayoutStoreDataSource` (for layout store data).
[[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95)
[[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30)
[[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42)
* Created a `MinimapDataSourceFactory` that selects the appropriate data
source based on the presence of layout store data, enabling seamless
switching between data models.

**Minimap rendering and logic refactoring:**

* Updated all minimap rendering functions (`renderGroups`,
`renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas`
entry point to use the unified data source interface, significantly
simplifying the rendering code and decoupling it from the underlying
graph structure.
[[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11)
[[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75)
[[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124)
[[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161)
[[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187)
[[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188)
[[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231)
[[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248)
* Refactored minimap viewport and graph change detection logic to use
the data source abstraction for bounds, node, and link change detection,
and to respond to layout store version changes.
[[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10)
[[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35)
[[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141)
[[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160)
[[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11)
[[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64)

These changes make the minimap codebase more modular and robust, and lay
the groundwork for supporting additional node layout strategies in the
future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1)
by [Unito](https://www.unito.io)
2025-09-19 13:52:57 -07:00
Benjamin Lu
893409dfc8 Add playwright tests for links and slots in vue nodes mode (#5668)
Tests added
- Should show a link dragging out from a slot when dragging on a slot
- Should create a link when dropping on a compatible slot
- Should not create a link when dropping on an incompatible slot(s)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5668-Add-playwright-tests-for-links-and-slots-in-vue-nodes-mode-2736d73d36508188a47dceee5d1a11e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-19 13:51:47 -07:00
Christian Byrne
df2fda6077 [refactor] Replace manual semantic version utilities/functions with semver package (#5653)
## Summary
- Replace custom `compareVersions()` with `semver.compare()`
- Replace custom `isSemVer()` with `semver.valid()`  
- Remove deprecated version comparison functions from `formatUtil.ts`
- Update all version comparison logic across components and stores
- Fix tests to use semver mocking instead of formatUtil mocking

## Benefits
- **Industry standard**: Uses well-maintained, battle-tested `semver`
package
- **Better reliability**: Handles edge cases more robustly than custom
implementation
- **Consistent behavior**: All version comparisons now use the same
underlying logic
- **Type safety**: Better TypeScript support with proper semver types


Fixes #4787

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5653-refactor-Replace-manual-semantic-version-utilities-functions-with-semver-package-2736d73d365081fb8498ee11cbcc10e2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 12:27:49 -07:00
Christian Byrne
4f5bbe0605 [refactor] Remove legacy manager UI support and tag from header (#5665)
## Summary

Removed the informational "Use Legacy UI" tag from the ManagerHeader
component while preserving all underlying legacy manager functionality.

## Changes

- **What**: Removed Tag component displaying legacy UI information from
ManagerHeader
- **Breaking**: None - all legacy manager functionality remains intact
- **Dependencies**: None

## Review Focus

Visual cleanup only - the `--enable-manager-legacy-ui` CLI flag and all
related functionality continues to work normally. Only the informational
UI tag has been removed from the header.
2025-09-19 02:07:51 -07:00
Christian Byrne
a975e50f1b [feat] Add tooltip support for Vue nodes (#5577)
## Summary

Added tooltip support for Vue node components using PrimeVue's v-tooltip
directive with proper data integration and container scoping.


https://github.com/user-attachments/assets/d1af31e6-ef6a-4df8-8de4-5098aa4490a1

## Changes

- **What**: Implemented tooltip functionality for Vue node headers,
input/output slots, and widgets using [PrimeVue
v-tooltip](https://primevue.org/tooltip/) directive
- **Dependencies**: Leverages existing PrimeVue tooltip system, no new
dependencies

## Review Focus

Container scoping implementation via provide/inject pattern for tooltip
positioning, proper TypeScript interfaces eliminating `as any` casts,
and integration with existing settings store for tooltip delays and
enable/disable functionality.

```mermaid
graph TD
    A[LGraphNode Container] --> B[provide tooltipContainer]
    B --> C[NodeHeader inject]
    B --> D[InputSlot inject]
    B --> E[OutputSlot inject]
    B --> F[NodeWidgets inject]

    G[useNodeTooltips composable] --> H[NodeDefStore lookup]
    G --> I[Settings integration]
    G --> J[i18n fallback]

    C --> G
    D --> G
    E --> G
    F --> G

    style A fill:#f9f9f9,stroke:#333,color:#000
    style G fill:#e8f4fd,stroke:#0066cc,color:#000
```

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-19 01:07:50 -07:00
Christian Byrne
a17c74fa0c fix: add optional chaining to nodeDef access in NodeTooltip (#5663)
## Summary

Extension of https://github.com/Comfy-Org/ComfyUI_frontend/pull/5659:
Added optional chaining to NodeTooltip component to prevent TypeError
when `nodeDef` is undefined for unknown node types.

## Changes

- **What**: Added [optional chaining
operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
(`?.`) to safely access `nodeDef` properties in NodeTooltip component

## Review Focus

Error handling for node types not found in nodeDefStore and tooltip
display behavior for unrecognized nodes.

Fixes CLOUD-FRONTEND-STAGING-3N

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 01:04:12 -07:00
Christian Byrne
5e625a5002 [test] add Vue FormSelectButton widget component tests (#5576)
## Summary

Added comprehensive component tests for FormSelectButton widget with 497
test cases covering all interaction patterns and edge cases.

## Changes

- **What**: Created test suite for
[FormSelectButton.vue](https://vuejs.org/guide/scaling-up/testing.html)
component with full coverage of string/number/object options, PrimeVue
compatibility, disabled states, and visual styling
- **Dependencies**: No new dependencies (uses existing vitest,
@vue/test-utils)

## Review Focus

Test completeness covering edge cases like unicode characters, duplicate
values, and objects with missing properties. Verify test helper
functions correctly simulate user interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5576-Add-Vue-FormSelectButton-widget-component-tests-26f6d73d36508171ae08ee74d0605db2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 00:12:25 -07:00
Christian Byrne
002fac0232 [refactor] Migrate manager code to DDD structure (#5662)
## Summary

Reorganized custom nodes manager functionality from scattered technical
layers into a cohesive domain-focused module following [domain-driven
design](https://en.wikipedia.org/wiki/Domain-driven_design) principles.

## Changes

- **What**: Migrated all manager code from technical layers
(`src/components/`, `src/stores/`, etc.) to unified domain structure at
`src/workbench/extensions/manager/`
- **Breaking**: Import paths changed for all manager-related modules
(40+ files updated)

## Review Focus

Verify all import path updates are correct and no circular dependencies
introduced. Check that [Vue 3 composition
API](https://vuejs.org/guide/reusability/composables.html) patterns
remain consistent across relocated composables.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5662-refactor-Migrate-manager-code-to-DDD-structure-2736d73d3650812c87faf6ed0fffb196)
by [Unito](https://www.unito.io)
2025-09-19 00:03:05 -07:00
Christian Byrne
7e115543fa fix: prevent TypeError when nodeDef is undefined in NodeTooltip (#5659)
## Summary

Fix TypeError in NodeTooltip component when `nodeDef` is undefined. This
occurs when hovering over nodes whose type is not found in the
nodeDefStore.

## Changes

- Add optional chaining (`?.`) to `nodeDef.description` access on line
71
- Follows the same defensive pattern used in previous fixes for similar
issues

## Context

This addresses Sentry issue
[CLOUD-FRONTEND-STAGING-1B](https://comfy-org.sentry.io/issues/6829258525/)
which shows 19 occurrences affecting 14 users.

The fix follows the same pattern as previous commits:
-
[290bf52fc](290bf52fc5)
- Fixed similar issue on line 112
-
[e8997a765](e8997a7653)
- Fixed multiple similar issues


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5659-fix-prevent-TypeError-when-nodeDef-is-undefined-in-NodeTooltip-2736d73d3650816e8be3f44889198b58)
by [Unito](https://www.unito.io)
2025-09-18 23:53:58 -07:00
Christian Byrne
80d75bb164 fix TypeError: nodes is not iterable when loading graph (#5660)
## Summary
- Fixes Sentry issue CLOUD-FRONTEND-STAGING-29 (TypeError: nodes is not
iterable)
- Adds defensive guard to check if nodes is valid array before iteration
- Gracefully handles malformed workflow data by skipping node processing

## Root Cause
The `collectMissingNodesAndModels` function in `src/scripts/app.ts:1135`
was attempting to iterate over `nodes` without checking if it was a
valid iterable, causing crashes when workflow data was malformed or
missing the nodes property.

## Fix
Added null/undefined/array validation before the for-loop:
```typescript
if (\!nodes || \!Array.isArray(nodes)) {
  console.warn('Workflow nodes data is missing or invalid, skipping node processing', { nodes, path })
  return
}
```

Fixes CLOUD-FRONTEND-STAGING-29

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5660-fix-TypeError-nodes-is-not-iterable-when-loading-graph-2736d73d365081cfb828d27e59a4811c)
by [Unito](https://www.unito.io)
2025-09-18 23:27:42 -07:00
snomiao
d59885839a fix: correct Claude PR review to use BASE_SHA for accurate diff comparison (#5654)
## Summary
- Fixes the Claude automated PR review comparing against wrong commits
- Updates the comprehensive-pr-review.md command to use `$BASE_SHA`
instead of `origin/$BASE_BRANCH`
- Resolves issue where Claude was reviewing unrelated changes from other
PRs

## Problem
As identified in #5651 (comment
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5651#issuecomment-3310416767),
the Claude automated review was incorrectly analyzing changes that
weren't part of the PR being reviewed. The review was mentioning Turkish
language removal, linkRenderer changes, and other modifications that
weren't in the actual PR diff.

## Root Cause Analysis

### The Issue Explained (from Discord discussion)
When Christian Byrne noticed Claude was referencing things from previous
reviews on other PRs, we investigated and found:

1. **The backport branch was created from origin/main BEFORE Turkish
language support was merged**
   - Branch state: `main.A`
   - Backport changes committed: `main.A.Backport`

2. **Turkish language support was then merged into origin/main**
   - Main branch updated to: `main.A.Turkish`

3. **Claude review workflow checked out `main.A.Backport` and ran git
diff against `origin/main`**
   - This compared: `main.A.Backport <> main.A.Turkish`
   - The diff showed: `+++Backport` changes and `---Turkish` removal
   - Because the common parent of both branches was `main.A`

### Why This Happens
When using `origin/$BASE_BRANCH`, git resolves to the latest commit on
that branch. The diff includes:
1. The PR's actual changes (+++Backport)
2. The reverse of all commits merged to main since the PR was created
(---Turkish)

This causes Claude to review changes that appear as "removals" of code
from other merged PRs, leading to confusing comments about unrelated
code.

## Solution
Changed the git diff commands to use `$BASE_SHA` directly, which GitHub
Actions provides as the exact commit SHA that represents the merge base.
This ensures Claude only reviews the actual changes introduced by the
PR.

### Before (incorrect):
```bash
git diff --name-only "origin/$BASE_BRANCH"  # Compares against latest main
git diff "origin/$BASE_BRANCH"
git diff --name-status "origin/$BASE_BRANCH"
```

### After (correct):
```bash
git diff --name-only "$BASE_SHA"  # Compares against merge base
git diff "$BASE_SHA"
git diff --name-status "$BASE_SHA"
```

## Technical Details

### GitHub Actions Environment Variables
- `BASE_SHA`: The commit SHA of the merge base (where PR branched from
main)
- `BASE_BRANCH`: Not provided by GitHub Actions (this was the bug)
- Using `origin/$BASE_BRANCH` was falling back to comparing against the
latest main commit

### Alternative Approaches Considered
1. **Approach 1**: Rebase/update branch before running Claude review
   - Downside: Changes the PR's commits, not always desirable
2. **Approach 2**: Use BASE_SHA to diff against the merge base 
   - This is what GitHub's PR diff view does
   - Shows only the changes introduced by the PR

## Testing
The BASE_SHA environment variable is already correctly set in the
claude-pr-review.yml workflow (line 88), so this change will work
immediately once merged.

## Impact
- Claude reviews will now be accurate and only analyze the actual PR
changes
- No false positives about "removed" code from other PRs
- More reliable automated PR review process
- Developers won't be confused by comments about code they didn't change

## Verification
You can verify this fix by:
1. Creating a PR from an older branch
2. Merging another PR to main
3. Triggering Claude review with the label
4. Claude should only review the PR's changes, not show removals from
the newly merged commits

## Credits
Thanks to @Christian-Byrne for reporting the issue and @snomiao for the
root cause analysis.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 22:09:20 -07:00
snomiao
cbb0f765b8 feat: enable verbatimModuleSyntax in TypeScript config (#5533)
## Summary
- Enable `verbatimModuleSyntax` compiler option in TypeScript
configuration
- Update all type imports to use explicit `import type` syntax
- This change will Improve tree-shaking and bundler compatibility

## Motivation
The `verbatimModuleSyntax` option ensures that type-only imports are
explicitly marked with the `type` keyword. This:
- Makes import/export intentions clearer
- Improves tree-shaking by helping bundlers identify what can be safely
removed
- Ensures better compatibility with modern bundlers
- Follows TypeScript best practices for module syntax

## Changes
- Added `"verbatimModuleSyntax": true` to `tsconfig.json`
- Updated another 48+ files to use explicit `import type` syntax for
type-only imports
- No functional changes, only import/export syntax improvements

## Test Plan
- [x] TypeScript compilation passes
- [x] Build completes successfully  
- [x] Tests pass
- [ ] No runtime behavior changes

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5533-feat-enable-verbatimModuleSyntax-in-TypeScript-config-26d6d73d36508190b424ef9b379b5130)
by [Unito](https://www.unito.io)
2025-09-18 21:05:56 -07:00
Christian Byrne
726a2fbbc9 feat: add manual dispatch to backport workflow (#5651)
Enables manual backport triggering for scenarios where labels are added
after PR merge.

Adds workflow_dispatch trigger to the backport workflow with support
for:
- Specifying PR number to backport post-merge
- Force rerun option to override duplicate detection  
- Proper handling of multi-version backport scenarios

Solves the issue where adding version labels (e.g., 1.27) after a PR is
already merged and backported (e.g., to 1.26) would not trigger
additional backports.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5651-feat-add-manual-dispatch-to-backport-workflow-2736d73d365081b6ba00c7a43c9ba06b)
by [Unito](https://www.unito.io)
2025-09-18 21:01:07 -07:00
snomiao
553b5aa02b feat: Add Turkish language support (#5438)
## Summary
- Added complete Turkish language translation for ComfyUI Frontend
- Integrated Turkish locale into the i18n system
- Added Turkish as a selectable language option in settings

## Implementation Details
- Added Turkish translation files provided by @naxci1:
  - `src/locales/tr/main.json` - Main UI translations
  - `src/locales/tr/commands.json` - Command translations
  - `src/locales/tr/nodeDefs.json` - Node definitions translations
  - `src/locales/tr/settings.json` - Settings translations
- Updated `src/i18n.ts` to import and register Turkish locale
- Added Turkish option to language selector in
`src/constants/coreSettings.ts`

## Test Plan
- [ ] Verify Turkish translations load correctly
- [ ] Test language switching to/from Turkish
- [ ] Check all UI elements display properly in Turkish
- [ ] Verify node descriptions and tooltips in Turkish
- [ ] Test command palette in Turkish

Fixes #5437

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5438-feat-Add-Turkish-language-support-2686d73d36508184bbf2dc1e0cd15350)
by [Unito](https://www.unito.io)
2025-09-18 19:43:53 -07:00
Benjamin Lu
2ff0d951ed Slot functionality for vue nodes (#5628)
Allows for simple slot functionality in vue nodes mode.

Has:
- Drag new link from slot
- Connect new link from dropping on slot

Now:
- Tests

After:
- Drop on reroute
- Correct link color on connect
- Drop on node
- Hover effects

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5628-Slot-functionality-for-vue-nodes-2716d73d365081c59a3cef7c8a5e539e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 19:35:15 -07:00
Christian Byrne
1f88925144 fix: don't immediately close missing nodes dialog if manager is disabled (#5647)
If manager is disabled, it assumed all missing nodes are installed and
immediately closes the missing nodes warning when loading a workflow
with missing nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5647-fix-don-t-immediately-close-missing-nodes-dialog-if-manager-is-disabled-2736d73d36508199a50bca2026528ab6)
by [Unito](https://www.unito.io)
2025-09-18 17:54:37 -07:00
AustinMroz
250433a91a Fix SaveAs (#5643)
Implementing subgraph blueprints (#5139) included changes to saving to
ensure that SaveAs generates a new workflow of the correct type. However
this code failed to utilize the pre-prepared state when performing the
actual save. This produced a couple of problems with both failing to
detach the workflow and failing to apply the correct state

This error is only encountered when using Save As from a non temporary
workflow (one loaded from the workflows sidebar tab).

As this state calculation code is only used in this code path, it has
been moved into the saveAs function of the workflowStore.

Resolves #5592

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5643-Fix-SaveAs-2726d73d3650818faa7af449d1f13c26)
by [Unito](https://www.unito.io)
2025-09-18 16:56:49 -07:00
AustinMroz
eb664f47af Fix cyclic prototype errors with subgraphNodes (#5637)
#5024 added support for connecting primitive nodes to subgraph inputs.
To accomplish this, it pulls WidgetLocator information from the node
owning the widget.

This `node` property does not exist on all IBaseWidget. `toConcrete` was
used to instead have a BaseWidget which is guaranteed to have a node
property. The issue that was missed, is that a widget which lacks this
information (such as most implemented by custom nodes) sets the node
value to the argument which was passed. Here that is the reference to
the subgraph node. Sometimes, this `#setWidget` call is made multiple
times, and when this occurs, the `input.widget` has itself set as the
protoyep, throwing an error.

This is resolved by instead taking an additional input which is
unambiguous.

For reference, this is a near minimal workflow using comfy_mtb that
replicates the issue

[cyclic.json](https://github.com/user-attachments/files/22412187/cyclic.json)

Special thanks to @melMass for assistance discovering this issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5637-Fix-cyclic-prototype-errors-with-subgraphNodes-2726d73d365081fea356f5197e4c2b42)
by [Unito](https://www.unito.io)
2025-09-18 16:06:12 -07:00
Christian Byrne
bc85d4e87b Make Vue nodes read-only when in panning mode (#5574)
## Summary

Integrated Vue node components with canvas panning mode to prevent UI
interference during navigation.

## Changes

- **What**: Added
[canCapturePointerEvents](https://docs.comfy.org/guide/vue-nodes)
computed property to `useCanvasInteractions` composable that checks
canvas read-only state
- **What**: Modified Vue node components (LGraphNode, NodeWidgets) to
conditionally handle pointer events based on canvas navigation mode
- **What**: Updated node event handlers to respect panning mode and
forward events to canvas when appropriate

## Review Focus

Event forwarding logic in panning mode and pointer event capture state
management across Vue node hierarchy.

```mermaid
graph TD
    A[User Interaction] --> B{Canvas in Panning Mode?}
    B -->|Yes| C[Forward to Canvas]
    B -->|No| D[Handle in Vue Component]
    C --> E[Canvas Navigation]
    D --> F[Node Selection/Widget Interaction]

    G[canCapturePointerEvents] --> H{read_only === false}
    H -->|Yes| I[Allow Vue Events]
    H -->|No| J[Block Vue Events]

    style A fill:#f9f9f9,stroke:#333,color:#000
    style E fill:#f9f9f9,stroke:#333,color:#000
    style F fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#e1f5fe,stroke:#01579b,color:#000
    style J fill:#ffebee,stroke:#c62828,color:#000
```

## Screenshots




https://github.com/user-attachments/assets/00dc5e4a-2b56-43be-b92e-eaf511e52542

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5574-Make-Vue-nodes-read-only-when-in-panning-mode-26f6d73d3650818c951cd82c8fe58972)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 15:43:35 -07:00
Comfy Org PR Bot
7585444ce6 1.28.0 (#5640)
Minor version increment to 1.28.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5640-1-28-0-2726d73d3650818e846fcf78cbf33b73)
by [Unito](https://www.unito.io)

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-18 14:13:33 -07:00
Robin Huang
a886798a10 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:09:16 -07:00
Christian Byrne
37975e4eac [test] Add component test for image compare widget (#5549)
## Summary

Added comprehensive component test suite for WidgetImageCompare widget
with 410 test assertions covering display, edge cases, and integration
scenarios.

## Changes

- **What**: Created [Vue Test Utils](https://vue-test-utils.vuejs.org/)
test suite for [WidgetImageCompare
component](src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue)
using [Vitest](https://vitest.dev/) testing framework

## Review Focus

Test coverage completeness for string vs object value handling,
accessibility attribute propagation, and edge case robustness including
malformed URLs and empty states.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5549-test-Add-component-test-for-image-compare-widget-26e6d73d365081189fe0d010f87d1eec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-18 13:44:21 -07:00
Jin Yi
a41b8a6d4f refactor: Change manager flag from --disable-manager to --enable-manager (#5635)
## Summary
- Updated frontend to align with backend changes in ComfyUI core PR
#7555
- Changed manager startup argument from `--disable-manager` (opt-out) to
`--enable-manager` (opt-in)
- Manager is now disabled by default unless explicitly enabled

## Changes
- Modified `useManagerState.ts` to check for `--enable-manager` flag
presence
- Inverted logic: manager is disabled when the flag is NOT present
- Updated all related tests to reflect the new opt-in behavior
- Fixed edge case where `systemStats` is null

## Related
- Backend PR: https://github.com/comfyanonymous/ComfyUI/pull/7555

## Test Plan
- [x] All unit tests pass
- [x] Verified manager state logic with different flag combinations
- [x] TypeScript type checking passes
- [x] Linting passes

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5635-refactor-Change-manager-flag-from-disable-manager-to-enable-manager-2726d73d36508153a88bd9f152132b2a)
by [Unito](https://www.unito.io)
2025-09-18 11:45:07 -07:00
Alexander Brown
b264685052 lint: add tsconfig for browser_tests, fix existing violations (#5633)
## Summary

See https://typescript-eslint.io/blog/project-service/ for context.
Creates a browser_tests specific tsconfig so that they can be linted.

Does not add a package.json script to do the linting yet, but `pnpm exec
eslint browser_tests` should work for now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5633-lint-add-tsconfig-for-browser_tests-fix-existing-violations-2726d73d3650819d8ef2c4b0abc31e14)
by [Unito](https://www.unito.io)
2025-09-18 11:35:44 -07:00
Johnpaul Chiwetelu
78d0ea6fa5 LazyImage on Safari (#5626)
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.

**Lazy loading behavior improvements:**

* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)

**Caching strategy enhancement:**

* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
2025-09-18 11:20:19 -07:00
Christian Byrne
ea4e57b602 Move VueFire persistence configuration to initialization (#5614)
Currently, we set persistence method in the auth store setup. This
creates pattern of using the default on init (indexed DB) up until the
firebase store is initialized and `setPersistence` is called. For
devices that don't support indexed DB or have the connection aggresively
terminated or cleared, like
[Safari](https://comfy-org.sentry.io/issues/6879071102/?project=4509681221369857&query=is%3Aunresolved&referrer=issue-stream),
this can create problems with maintaing auth persistence.

Fix by setting persistence method in the initialization in main.ts

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5614-Move-VueFire-persistence-configuration-to-initialization-2716d73d3650817480e0c8feb1f37b9a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 11:18:05 -07:00
Johnpaul Chiwetelu
4789d86fe8 Line Selection toolbox up with Vue Nodes (#5601)
This pull request improves the selection toolbox behavior during node
dragging by ensuring that it correctly responds to both LiteGraph and
Vue node drag events. The main changes introduce a reactive drag state
for Vue nodes in the layout store and update the selection toolbox
composable and Vue node component to use this state.

**Selection toolbox behavior improvements:**

* Added a helper function and separate watchers in
`useSelectionToolboxPosition.ts` to hide the selection toolbox when
either LiteGraph or Vue nodes are being dragged. This ensures consistent
UI feedback regardless of node type.
[[1]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713L171-R172)
[[2]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713R212-R224)

**Vue node drag state management:**

* Added a reactive `isDraggingVueNodes` property to the
`LayoutStoreImpl` class, along with getter and setter methods to manage
Vue node drag state. This allows other components to reactively track
when Vue nodes are being dragged.
[[1]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR133-R135)
[[2]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR354-R367)
* Updated `LGraphNode.vue` to set and clear the Vue node dragging state
in the layout store during pointer down and up events, ensuring the
selection toolbox is hidden while dragging Vue nodes.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R357-R360)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R376-R378)

**Dependency updates:**

* Imported the `layoutStore` in `LGraphNode.vue` to access the new drag
state management methods.
* Added missing `ref` import in `layoutStore.ts` to support the new
reactive property.



https://github.com/user-attachments/assets/d6e9c15e-63b5-4de2-9688-ebbc6a3be545

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 11:17:14 -07:00
filtered
09e7d1040e Add desktop dialogs framework (#5605)
### Summary

Adds desktop dialog framework with data-driven dialog definitions.

### Changes

- Data-driven dialog structure in `desktopDialogs.ts`
- Dynamic dialog view component with i18n support
- Button action types: openUrl, close, cancel
- Button severity levels for styling (primary, secondary, danger, warn)
- Fallback invalid dialog for error handling
- i18n collection script updated for dialog strings
2025-09-17 20:32:53 -07:00
Arjan Singh
dfa1cbba4f Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory

* [feat] add AssetBrowserModal

And all related sub components

* [feat] reactive filter functions

* [ci] clean up storybook config

* [feat] add sematic AssetCard

* [fix] i love lucide

* [fix] AssetCard layout issues

* [fix] add AssetBadge type

* [fix] simplify useAssetBrowser

* [fix] modal layout

* [fix] simplify useAssetBrowserDialog

* [fix] add tailwind back to storybook

* [fix] better reponsive layout

* [fix] missed i18n string

* [fix] missing i18n translations

* [fix] remove erroneous prevent on keyboard.space

* [feat] add asset metadata validation utilities

* [fix] remove erroneous test code

* [fix] remove forced min and max width on AssetCard

* [fix] import statement nits
2025-09-17 16:17:09 -07:00
Alexander Brown
08220d50d9 Lint: Turn on rules that should allow for verbatimModuleSyntax (#5616)
* lint: turn on type import rules setting up for verbatimModuleSyntax

* lint: --fix for type imports
2025-09-16 22:03:41 -07:00
171 changed files with 6963 additions and 20358 deletions

View File

@@ -1,15 +0,0 @@
name: Validate JSON
on:
push:
branches:
- main
pull_request:
jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -88,8 +88,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Build types
run: pnpm build:types
@@ -133,7 +131,7 @@ jobs:
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}"
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -65,7 +65,6 @@ export const withTheme = (Story: any, context: any) => {
return Story()
}
const preview: Preview = {
parameters: {
controls: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1012,8 +1012,6 @@ test.describe('Canvas Navigation', () => {
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -5,14 +5,13 @@ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
const extraFileExtensions = ['.vue']
export default defineConfig([
export default [
{
files: ['src/**/*.{js,mjs,cjs,ts,vue}']
},
{
ignores: [
'src/scripts/*',
@@ -25,58 +24,35 @@ export default defineConfig([
]
},
{
files: ['./**/*.{ts,mts}'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: tseslint.parser,
parserOptions: {
parser: tseslint.parser,
projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts',
'playwright.config.ts',
'playwright.i18n.config.ts',
'scripts/collect-i18n-node-defs.ts'
]
},
tsConfigRootDir: import.meta.dirname,
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
}
}
},
{
files: ['./**/*.vue'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
projectService: true,
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
extraFileExtensions: ['.vue']
}
}
},
pluginJs.configs.recommended,
tseslint.configs.recommended,
pluginVue.configs['flat/recommended'],
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
storybook.configs['flat/recommended'],
{
files: ['src/**/*.vue'],
languageOptions: {
parserOptions: {
parser: tseslint.parser
}
}
},
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Bad types in the plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
@@ -91,7 +67,6 @@ export default defineConfig([
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',
@@ -160,5 +135,6 @@ export default defineConfig([
}
]
}
}
])
},
...storybook.configs['flat/recommended']
]

View File

@@ -25,9 +25,7 @@ const config: KnipConfig = {
'src/types/generatedManagerTypes.ts',
'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Staged for for use with subgraph widget promotion
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
'src/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -3,13 +3,13 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
'vue-tsc --noEmit'
]
}
function formatAndEslint(fileNames) {
return [
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
`eslint --fix ${fileNames.join(' ')}`,
`prettier --write ${fileNames.join(' ')}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.10",
"version": "1.27.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -14,9 +14,9 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx nx e2e",
"test:unit": "nx run test tests-ui/tests",
@@ -38,10 +38,10 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
"@nx/eslint": "21.4.1",
"@nx/playwright": "21.4.1",
@@ -64,11 +64,11 @@
"@vitest/ui": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-vue": "^10.4.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-storybook": "^9.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
"happy-dom": "^15.11.0",
@@ -79,23 +79,22 @@
"lint-staged": "^15.2.7",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.6",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.44.0",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"uuid": "^11.1.0",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
},

View File

@@ -7,7 +7,6 @@ export default defineConfig({
headless: true
},
reporter: 'list',
workers: 1,
timeout: 60000,
testMatch: /collect-i18n-.*\.ts/
})

1048
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,77 +0,0 @@
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: $0 [--debug]" >&2
}
debug=0
while [ "$#" -gt 0 ]; do
case "$1" in
--debug)
debug=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 2
;;
esac
shift
done
# Validate JSON syntax in tracked files using jq
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not installed" >&2
exit 127
fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
# shellcheck disable=SC2206
EXCLUDE_PATTERNS+=( ${JSON_LINT_EXCLUDES} )
fi
pathspecs=(-- '*.json')
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
if [[ ${pattern:0:1} == ':' ]]; then
pathspecs+=("$pattern")
else
pathspecs+=(":(glob,exclude)${pattern}")
fi
done
mapfile -t json_files < <(git ls-files "${pathspecs[@]}")
if [ "${#json_files[@]}" -eq 0 ]; then
echo 'No JSON files found.'
exit 0
fi
if [ "$debug" -eq 1 ]; then
echo 'JSON files to validate:'
printf ' %s\n' "${json_files[@]}"
fi
failed=0
for file in "${json_files[@]}"; do
if ! jq -e . "$file" >/dev/null; then
echo "Invalid JSON syntax: $file" >&2
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
echo 'JSON validation failed.' >&2
exit 1
fi
echo 'All JSON files are valid.'

View File

@@ -2,7 +2,6 @@ import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
@@ -132,23 +131,6 @@ test('collect-i18n-general', async ({ comfyPage }) => {
])
)
// Desktop Dialogs
const allDesktopDialogsLocale = Object.fromEntries(
Object.values(DESKTOP_DIALOGS).map((dialog) => [
normalizeI18nKey(dialog.id),
{
title: dialog.title,
message: dialog.message,
buttons: Object.fromEntries(
dialog.buttons.map((button) => [
normalizeI18nKey(button.label),
button.label
])
)
}
])
)
fs.writeFileSync(
localePath,
JSON.stringify(
@@ -162,8 +144,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
...allSettingCategoriesLocale
},
serverConfigItems: allServerConfigsLocale,
serverConfigCategories: allServerConfigCategoriesLocale,
desktopDialogs: allDesktopDialogsLocale
serverConfigCategories: allServerConfigCategoriesLocale
},
null,
2

View File

@@ -1,9 +1,9 @@
import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import type { ComfyNodeDef } from '../src/schemas/nodeDefSchema'
import type { ComfyApi } from '../src/scripts/api'
import { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json'
@@ -11,28 +11,23 @@ const nodeDefsPath = './src/locales/en/nodeDefs.json'
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
await comfyPage.page.route('**/view**', async (route) => {
comfyPage.page.route('**/view**', async (route) => {
await route.fulfill({
body: JSON.stringify({})
})
})
// Note: Don't mock the object_info API endpoint - let it hit the actual backend
const nodeDefs: ComfyNodeDefImpl[] = await comfyPage.page.evaluate(
async () => {
const api = window['app'].api
const rawNodeDefs = await api.getNodeDefs()
const { ComfyNodeDefImpl } = await import('../src/stores/nodeDefStore')
return (
Object.values(rawNodeDefs)
// Ignore DevTools nodes (used for internal testing)
.filter((def: ComfyNodeDef) => !def.name.startsWith('DevTools'))
.map((def: ComfyNodeDef) => new ComfyNodeDefImpl(def))
)
}
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
const api = window['app'].api as ComfyApi
return await api.getNodeDefs()
})
) as ComfyNodeDef[]
)
// Ignore DevTools nodes (used for internal testing)
.filter((def) => !def.name.startsWith('DevTools'))
.map((def) => new ComfyNodeDefImpl(def))
console.log(`Collected ${nodeDefs.length} node definitions`)

View File

@@ -1,17 +0,0 @@
/* Inter Font Family */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-normal.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-italic.woff2') format('woff2');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}

View File

@@ -1,6 +1,5 @@
@layer theme, base, primevue, components, utilities;
@import './fonts.css';
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@@ -53,20 +52,15 @@
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--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;
@@ -105,16 +99,12 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-bypass: #6A246A;
--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-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
--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 */
@@ -127,10 +117,10 @@
}
@theme inline {
--color-node-component-surface: var(--color-charcoal-600);
--color-node-component-surface: var(--color-charcoal-300);
--color-node-component-surface-highlight: var(--color-slate-100);
--color-node-component-surface-hovered: var(--color-charcoal-400);
--color-node-component-surface-selected: var(--color-charcoal-200);
--color-node-component-surface-hovered: var(--color-charcoal-500);
--color-node-component-surface-selected: var(--color-charcoal-700);
--color-node-stroke: var(--color-stone-100);
}
@@ -142,7 +132,7 @@
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {

View File

@@ -0,0 +1,546 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BatchCountEdit from './BatchCountEdit.vue'
const meta: Meta = {
title: 'Components/Actionbar/BatchCountEdit',
component: BatchCountEdit,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'BatchCountEdit allows users to set the batch count for queue operations with smart increment/decrement logic. Features exponential scaling (doubling/halving) and integrates with the queue settings store for ComfyUI workflow execution. This component can accept props for controlled mode or use Pinia store state by default.'
}
}
},
argTypes: {
minQueueCount: {
control: 'number',
description: 'Minimum allowed batch count',
table: {
defaultValue: { summary: '1' }
}
},
maxQueueCount: {
control: 'number',
description: 'Maximum allowed batch count',
table: {
defaultValue: { summary: '100' }
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const Default: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Batch Count Editor</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Set the number of times to run the workflow. Smart increment/decrement with exponential scaling.
</p>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<strong>Note:</strong> Current value: {{count}}. Check console for action logs.
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Default batch count editor with smart exponential scaling. Uses Pinia store for state management. Click +/- buttons to see the doubling/halving behavior.'
}
}
}
}
export const WithTooltip: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 4,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 40px;">
<div style="margin-bottom: 16px; text-align: center;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
Hover over the input to see tooltip
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; text-align: center; margin-top: 20px;">
⬆️ Tooltip appears on hover with 600ms delay
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit with tooltip functionality - hover to see the "Batch Count" tooltip.'
}
}
}
}
export const HighBatchCount: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 16,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
High batch count scenario (16 generations):
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 4px; padding: 12px;">
<div style="display: flex; align-items: center; gap: 8px; color: #b45309;">
<i class="pi pi-exclamation-triangle"></i>
<span style="font-size: 14px; font-weight: 600;">High Batch Count Warning</span>
</div>
<div style="font-size: 12px; color: #92400e; margin-top: 4px;">
Running 16 generations will consume significant GPU time and memory. Consider reducing batch size for faster iteration.
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'High batch count scenario showing potential performance warnings for large generation batches.'
}
}
}
}
export const ActionBarContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 2,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
BatchCountEdit in realistic action bar context:
</div>
<div style="display: flex; align-items: center; gap: 16px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
<!-- Mock Queue Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<!-- BatchCountEdit -->
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<!-- Mock Clear Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #6b7280; color: white; border: none; border-radius: 6px; cursor: pointer;">
<i class="pi pi-trash"></i>
Clear
</button>
<!-- Mock Settings -->
<button style="padding: 8px; background: none; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer;">
<i class="pi pi-cog" style="color: #6b7280;"></i>
</button>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit integrated within a realistic ComfyUI action bar layout with queue controls.'
}
}
}
}
export const ExponentialScaling: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
scalingLog: [],
currentValue: 1,
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
methods: {
simulateIncrement() {
const current = this.currentValue
const newValue = Math.min(current * 2, 100)
this.scalingLog.unshift(`Increment: ${current}${newValue} (×2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
simulateDecrement() {
const current = this.currentValue
const newValue = Math.floor(current / 2) || 1
this.scalingLog.unshift(`Decrement: ${current}${newValue} (÷2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
reset() {
this.currentValue = 1
this.scalingLog = []
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Exponential Scaling Demo</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Demonstrates the smart doubling/halving behavior of batch count controls.
</p>
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 600;">Current Value:</span>
<span style="font-size: 18px; font-weight: bold; color: #3b82f6;">{{ currentValue }}</span>
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button @click="simulateIncrement" style="padding: 6px 12px; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-plus"></i> Double
</button>
<button @click="simulateDecrement" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-minus"></i> Halve
</button>
<button @click="reset" style="padding: 6px 12px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-refresh"></i> Reset
</button>
</div>
<div v-if="scalingLog.length" style="background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Scaling Log:</div>
<div v-for="(entry, index) in scalingLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Demonstrates the exponential scaling behavior - increment doubles the value, decrement halves it.'
}
}
}
}
export const QueueWorkflowContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
queueStatus: 'Ready',
totalGenerations: 1,
estimatedTime: '~2 min',
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
computed: {
statusColor() {
return this.queueStatus === 'Ready'
? '#10b981'
: this.queueStatus === 'Running'
? '#f59e0b'
: '#6b7280'
}
},
methods: {
updateEstimate() {
// Simulate batch count change affecting estimates
this.totalGenerations = 1 // This would be updated by actual batch count
this.estimatedTime = `~${this.totalGenerations * 2} min`
},
queueWorkflow() {
this.queueStatus = 'Running'
setTimeout(() => {
this.queueStatus = 'Complete'
}, 3000)
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Queue Workflow Context</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
BatchCountEdit within a complete workflow queuing interface.
</p>
</div>
<!-- Mock Workflow Preview -->
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<i class="pi pi-sitemap" style="color: #6366f1;"></i>
<span style="font-weight: 600;">SDXL Portrait Generation</span>
<span :style="{color: statusColor, fontSize: '12px', fontWeight: '600'}" style="background: rgba(0,0,0,0.05); padding: 2px 8px; border-radius: 12px;">
{{ queueStatus }}
</span>
</div>
<!-- Queue Controls -->
<div style="display: flex; align-items: center; gap: 12px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;">
<button @click="queueWorkflow" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="text-align: right;">
<div style="font-size: 12px; color: #6b7280;">Total: {{ totalGenerations }} generations</div>
<div style="font-size: 12px; color: #6b7280;">Est. time: {{ estimatedTime }}</div>
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit in a complete workflow queuing context with status and time estimates.'
}
}
}
}
export const LimitConstraints: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
},
scenarios: [
{
name: 'Conservative (max 10)',
maxLimit: 10,
description: 'For memory-constrained systems'
},
{
name: 'Standard (max 50)',
maxLimit: 50,
description: 'Typical production usage'
},
{
name: 'High-end (max 200)',
maxLimit: 200,
description: 'For powerful GPU setups'
}
]
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Limit Constraints</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Different batch count limits for various system configurations.
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
<div v-for="scenario in scenarios" :key="scenario.name" style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="font-weight: 600; margin-bottom: 4px;">{{ scenario.name }}</div>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">{{ scenario.description }}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; font-weight: 600;">BATCH:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
Max limit: {{ scenario.maxLimit }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Different batch count limit scenarios for various system configurations and use cases.'
}
}
}
}
export const MinimalInline: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 20
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 3,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Minimal inline usage:
</div>
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px;">
<span>Run</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
<span>times</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Minimal inline usage of BatchCountEdit within a sentence context.'
}
}
}
}

View File

@@ -40,15 +40,51 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
interface Props {
batchCount?: number
minQueueCount?: number
maxQueueCount?: number
}
interface Emits {
(e: 'update:batch-count', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
batchCount: undefined,
minQueueCount: 1,
maxQueueCount: undefined
})
const emit = defineEmits<Emits>()
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const { batchCount: storeBatchCount } = storeToRefs(queueSettingsStore)
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
const defaultMaxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
// Use props if provided, otherwise fallback to store values
const batchCount = computed({
get() {
return props.batchCount ?? storeBatchCount.value
},
set(value: number) {
if (props.batchCount !== undefined) {
emit('update:batch-count', value)
} else {
storeBatchCount.value = value
}
}
})
const minQueueCount = computed(() => props.minQueueCount)
const maxQueueCount = computed(
() => props.maxQueueCount ?? defaultMaxQueueCount.value
)
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {

View File

@@ -1,8 +1,5 @@
<template>
<div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
@@ -49,7 +46,7 @@ const hasSelection = ref(false)
const isHovered = useElementHover(rootEl)
const terminalData = useTerminal(terminalEl)
emit('created', terminalData, ref(rootEl))
emit('created', terminalData, rootEl)
const { terminal } = terminalData
let selectionDisposable: IDisposable | undefined
@@ -100,13 +97,12 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm {
@apply overflow-hidden;
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
@apply bg-neutral-900 overflow-hidden;
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -0,0 +1,152 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ContentDivider from './ContentDivider.vue'
const meta: Meta<typeof ContentDivider> = {
title: 'Components/Common/ContentDivider',
component: ContentDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'ContentDivider provides a visual separation between content sections. It supports both horizontal and vertical orientations with customizable width/thickness.'
}
}
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Direction of the divider line',
defaultValue: 'horizontal'
},
width: {
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
description: 'Width/thickness of the divider in pixels',
defaultValue: 0.3
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ContentDivider>
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
width: 0.3
},
parameters: {
docs: {
description: {
story:
'Default horizontal divider for separating content sections vertically.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const Vertical: Story = {
args: {
orientation: 'vertical',
width: 0.3
},
parameters: {
docs: {
description: {
story: 'Vertical divider for separating content sections horizontally.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 10px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 10px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const ThickHorizontal: Story = {
args: {
orientation: 'horizontal',
width: 2
},
parameters: {
docs: {
description: {
story:
'Thicker horizontal divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const ThickVertical: Story = {
args: {
orientation: 'vertical',
width: 3
},
parameters: {
docs: {
description: {
story: 'Thicker vertical divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,259 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import EditableText from './EditableText.vue'
const meta: Meta<typeof EditableText> = {
title: 'Components/Common/EditableText',
component: EditableText,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'EditableText allows inline text editing with sophisticated focus management and keyboard handling. It supports automatic text selection, smart filename handling (excluding extensions), and seamless transitions between view and edit modes.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'The text value to display and edit'
},
isEditing: {
control: 'boolean',
description: 'Whether the component is currently in edit mode'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof EditableText>
const createEditableStoryRender =
(
initialText = 'Click to edit this text',
initialEditing = false,
stayEditing = false
) =>
(args: any) => ({
components: { EditableText },
setup() {
const text = ref(args.modelValue || initialText)
const editing = ref(args.isEditing ?? initialEditing)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleEdit = (newValue: string) => {
logAction('Edit completed', newValue)
text.value = newValue
editing.value = stayEditing // Stay in edit mode if specified
}
const startEdit = () => {
editing.value = true
logAction('Edit started')
}
return { args, text, editing, actions, handleEdit, startEdit }
},
template: `
<div style="padding: 20px;">
<div @click="startEdit" style="cursor: pointer; border: 2px dashed #ccc; border-radius: 4px; padding: 20px;">
<div style="margin-bottom: 8px; font-size: 12px; color: #666;">Click text to edit:</div>
<EditableText
:modelValue="text"
:isEditing="editing"
@edit="handleEdit"
/>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createEditableStoryRender(),
args: {
modelValue: 'Click to edit this text',
isEditing: false
}
}
export const AlwaysEditing: Story = {
render: createEditableStoryRender('Always in edit mode', true, true),
args: {
modelValue: 'Always in edit mode',
isEditing: true
}
}
export const FilenameEditing: Story = {
render: () => ({
components: { EditableText },
setup() {
const filenames = ref([
'my_workflow.json',
'image_processing.png',
'model_config.yaml',
'final_render.mp4'
])
const actions = ref<string[]>([])
const logAction = (action: string, filename: string, newName: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(
`${action}: "${filename}" → "${newName}" (${timestamp})`
)
if (actions.value.length > 5) actions.value.pop()
console.log(action, { filename, newName })
}
const handleFilenameEdit = (index: number, newValue: string) => {
const oldName = filenames.value[index]
filenames.value[index] = newValue
logAction('Filename changed', oldName, newValue)
}
return { filenames, actions, handleFilenameEdit }
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: bold;">File Browser (click filenames to edit):</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(filename, index) in filenames" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f9f9f9; border-radius: 4px;">
<i style="margin-right: 8px; color: #666;" class="pi pi-file"></i>
<EditableText
:modelValue="filename"
:isEditing="false"
@edit="(newValue) => handleFilenameEdit(index, newValue)"
/>
</div>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
}
export const LongText: Story = {
render: createEditableStoryRender(
'This is a much longer text that demonstrates how the EditableText component handles longer content with multiple words and potentially line wrapping scenarios.'
),
args: {
modelValue:
'This is a much longer text that demonstrates how the EditableText component handles longer content.',
isEditing: false
}
}
export const EmptyState: Story = {
render: createEditableStoryRender(''),
args: {
modelValue: '',
isEditing: false
}
}
export const SingleCharacter: Story = {
render: createEditableStoryRender('A'),
args: {
modelValue: 'A',
isEditing: false
}
}
// ComfyUI usage examples
export const WorkflowNaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const workflows = ref([
'Portrait Enhancement',
'Landscape Generation',
'Style Transfer Workflow',
'Untitled Workflow'
])
const handleWorkflowRename = (index: number, newName: string) => {
workflows.value[index] = newName
console.log('Workflow renamed:', { index, newName })
}
return { workflows, handleWorkflowRename }
},
template: `
<div style="padding: 20px; width: 300px;">
<div style="margin-bottom: 16px; font-weight: bold;">Workflow Library</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div v-for="(workflow, index) in workflows" :key="index"
style="padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;">
<EditableText
:modelValue="workflow"
:isEditing="false"
@edit="(newName) => handleWorkflowRename(index, newName)"
style="font-size: 14px; font-weight: 500;"
/>
<div style="margin-top: 4px; font-size: 11px; color: #666;">
Last modified: 2 hours ago
</div>
</div>
</div>
</div>
`
})
}
export const ModelRenaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const models = ref([
'stable-diffusion-v1-5.safetensors',
'controlnet_depth.pth',
'vae-ft-mse-840000-ema.ckpt'
])
const handleModelRename = (index: number, newName: string) => {
models.value[index] = newName
console.log('Model renamed:', { index, newName })
}
return { models, handleModelRename }
},
template: `
<div style="padding: 20px; width: 350px;">
<div style="margin-bottom: 16px; font-weight: bold;">Model Manager</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(model, index) in models" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f8f8f8; border-radius: 4px;">
<i style="margin-right: 8px; color: #4a90e2;" class="pi pi-box"></i>
<EditableText
:modelValue="model"
:isEditing="false"
@edit="(newName) => handleModelRename(index, newName)"
style="flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;"
/>
</div>
</div>
</div>
`
})
}

View File

@@ -7,7 +7,7 @@
<InputText
v-else
ref="inputRef"
v-model:model-value="inputValue"
v-model:modelValue="inputValue"
v-focus
type="text"
size="small"

View File

@@ -0,0 +1,672 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { FormItem as FormItemType } from '@/types/settingTypes'
import FormItem from './FormItem.vue'
const meta: Meta = {
title: 'Components/Common/FormItem',
component: FormItem as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'FormItem is a generalized form component that dynamically renders different input types based on configuration. Supports text, number, boolean, combo, slider, knob, color, image, and custom renderer inputs with proper labeling and accessibility.'
}
}
},
argTypes: {
item: {
control: 'object',
description:
'FormItem configuration object defining the input type and properties'
},
formValue: {
control: 'text',
description: 'The current form value (v-model)',
defaultValue: ''
},
id: {
control: 'text',
description: 'Optional HTML id for the form input',
defaultValue: undefined
},
labelClass: {
control: 'text',
description: 'Additional CSS classes for the label',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const TextInput: Story = {
render: (args: any) => ({
components: { FormItem },
setup() {
return { args }
},
data() {
return {
value: args.formValue || 'Default text value',
textItem: {
name: 'Workflow Name',
type: 'text',
tooltip: 'Enter a descriptive name for your workflow',
attrs: {
placeholder: 'e.g., SDXL Portrait Generation'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Text value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Text input form item with tooltip:
</div>
<FormItem
:item="textItem"
:formValue="value"
@update:formValue="updateValue"
id="workflow-name"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current value: "{{ value }}"
</div>
</div>
`
}),
args: {
formValue: 'My Workflow'
},
parameters: {
docs: {
description: {
story:
'Text input FormItem with tooltip and placeholder. Hover over the info icon to see the tooltip.'
}
}
}
}
export const NumberInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 7.5,
numberItem: {
name: 'CFG Scale',
type: 'number',
tooltip:
'Classifier-free guidance scale controls how closely the AI follows your prompt',
attrs: {
min: 1,
max: 30,
step: 0.5,
showButtons: true
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('CFG scale updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Number input with controls and constraints:
</div>
<FormItem
:item="numberItem"
:formValue="value"
@update:formValue="updateValue"
id="cfg-scale"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current CFG scale: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Number input FormItem with min/max constraints and increment buttons for CFG scale parameter.'
}
}
}
}
export const BooleanToggle: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: false,
booleanItem: {
name: 'Enable GPU Acceleration',
type: 'boolean',
tooltip: 'Use GPU for faster processing when available'
} as FormItemType
}
},
methods: {
updateValue(newValue: boolean) {
console.log('GPU acceleration toggled:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Boolean toggle switch form item:
</div>
<FormItem
:item="booleanItem"
:formValue="value"
@update:formValue="updateValue"
id="gpu-accel"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
GPU acceleration: {{ value ? 'Enabled' : 'Disabled' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Boolean FormItem using ToggleSwitch component for enable/disable settings.'
}
}
}
}
export const ComboSelect: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'euler_a',
comboItem: {
name: 'Sampling Method',
type: 'combo',
tooltip: 'Algorithm used for denoising during generation',
options: [
'euler_a',
'euler',
'heun',
'dpm_2',
'dpm_2_ancestral',
'lms',
'dpm_fast',
'dpm_adaptive',
'dpmpp_2s_ancestral',
'dpmpp_sde',
'dpmpp_2m'
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Sampling method updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo select with sampling methods:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-method"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Combo select FormItem with ComfyUI sampling methods showing dropdown selection.'
}
}
}
}
export const SliderInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 0.7,
sliderItem: {
name: 'Denoise Strength',
type: 'slider',
tooltip:
'How much to denoise the input image (0 = no change, 1 = complete redraw)',
attrs: {
min: 0,
max: 1,
step: 0.01
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Denoise strength updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Slider input with precise decimal control:
</div>
<FormItem
:item="sliderItem"
:formValue="value"
@update:formValue="updateValue"
id="denoise-strength"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Denoise: {{ (value * 100).toFixed(0) }}%
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Slider FormItem for denoise strength with percentage display and fine-grained control.'
}
}
}
}
export const KnobInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 20,
knobItem: {
name: 'Sampling Steps',
type: 'knob',
tooltip:
'Number of denoising steps - more steps = higher quality but slower generation',
attrs: {
min: 1,
max: 150,
step: 1
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Steps updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Knob input for sampling steps:
</div>
<FormItem
:item="knobItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-steps"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Steps: {{ value }} ({{ value < 10 ? 'Very Fast' : value < 30 ? 'Fast' : value < 50 ? 'Balanced' : 'High Quality' }})
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Knob FormItem for sampling steps with quality indicator based on step count.'
}
}
}
}
export const MultipleFormItems: Story = {
render: () => ({
components: { FormItem },
data() {
return {
widthValue: 512,
heightValue: 512,
stepsValue: 20,
cfgValue: 7.5,
samplerValue: 'euler_a',
hiresValue: false
}
},
computed: {
formItems() {
return [
{
name: 'Width',
type: 'number',
tooltip: 'Image width in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Height',
type: 'number',
tooltip: 'Image height in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Sampling Steps',
type: 'knob',
tooltip: 'Number of denoising steps',
attrs: { min: 1, max: 150, step: 1 }
},
{
name: 'CFG Scale',
type: 'slider',
tooltip: 'Classifier-free guidance scale',
attrs: { min: 1, max: 30, step: 0.5 }
},
{
name: 'Sampler',
type: 'combo',
tooltip: 'Sampling algorithm',
options: ['euler_a', 'euler', 'heun', 'dpm_2', 'dpmpp_2m']
},
{
name: 'High-res Fix',
type: 'boolean',
tooltip: 'Enable high-resolution generation'
}
] as FormItemType[]
},
allSettings() {
return {
width: this.widthValue,
height: this.heightValue,
steps: this.stepsValue,
cfg: this.cfgValue,
sampler: this.samplerValue,
enableHires: this.hiresValue
}
}
},
template: `
<div style="padding: 20px; min-width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI Generation Settings</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multiple form items demonstrating different input types in a realistic settings panel.
</p>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="display: flex; flex-direction: column; gap: 16px;">
<FormItem
:item="formItems[0]"
:formValue="widthValue"
@update:formValue="(value) => widthValue = value"
id="form-width"
/>
<FormItem
:item="formItems[1]"
:formValue="heightValue"
@update:formValue="(value) => heightValue = value"
id="form-height"
/>
<FormItem
:item="formItems[2]"
:formValue="stepsValue"
@update:formValue="(value) => stepsValue = value"
id="form-steps"
/>
<FormItem
:item="formItems[3]"
:formValue="cfgValue"
@update:formValue="(value) => cfgValue = value"
id="form-cfg"
/>
<FormItem
:item="formItems[4]"
:formValue="samplerValue"
@update:formValue="(value) => samplerValue = value"
id="form-sampler"
/>
<FormItem
:item="formItems[5]"
:formValue="hiresValue"
@update:formValue="(value) => hiresValue = value"
id="form-hires"
/>
</div>
</div>
<div style="margin-top: 16px; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Current Settings:</div>
<div style="font-family: monospace; font-size: 12px; color: #4b5563;">
{{ JSON.stringify(allSettings, null, 2) }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Multiple FormItems demonstrating all major input types in a realistic ComfyUI settings panel.'
}
}
}
}
export const WithCustomLabels: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'custom_model.safetensors',
customItem: {
name: 'Model File',
type: 'text',
tooltip: 'Select the checkpoint model file to use for generation',
attrs: {
placeholder: 'Select or enter model filename...'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Model file updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
FormItem with custom label styling and slots:
</div>
<FormItem
:item="customItem"
:formValue="value"
@update:formValue="updateValue"
id="model-file"
:labelClass="{ 'font-bold': true, 'text-blue-600': true }"
>
<template #name-prefix>
<i class="pi pi-download" style="margin-right: 6px; color: #3b82f6;"></i>
</template>
<template #name-suffix>
<span style="margin-left: 6px; font-size: 10px; color: #ef4444;">*</span>
</template>
</FormItem>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected model: {{ value || 'None' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'FormItem with custom label styling and prefix/suffix slots for enhanced UI elements.'
}
}
}
}
export const ColorPicker: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: '#3b82f6',
colorItem: {
name: 'Theme Accent Color',
type: 'color',
tooltip: 'Primary accent color for the interface theme'
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Color updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Color picker form item:
</div>
<FormItem
:item="colorItem"
:formValue="value"
@update:formValue="updateValue"
id="theme-color"
/>
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<div style="font-size: 12px; color: #6b7280;">Preview:</div>
<div
:style="{
backgroundColor: value,
width: '40px',
height: '20px',
borderRadius: '4px',
border: '1px solid #e2e8f0'
}"
></div>
<span style="font-family: monospace; font-size: 12px;">{{ value }}</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Color picker FormItem with live preview showing the selected color value.'
}
}
}
}
export const ComboWithComplexOptions: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'medium',
comboItem: {
name: 'Quality Preset',
type: 'combo',
tooltip:
'Predefined quality settings that adjust multiple parameters',
options: [
{ text: 'Draft (Fast)', value: 'draft' },
{ text: 'Medium Quality', value: 'medium' },
{ text: 'High Quality', value: 'high' },
{ text: 'Ultra (Slow)', value: 'ultra' }
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Quality preset updated:', newValue)
this.value = newValue
}
},
computed: {
presetDescription() {
const descriptions = {
draft: 'Fast generation with 10 steps, suitable for previews',
medium: 'Balanced quality with 20 steps, good for most use cases',
high: 'High quality with 40 steps, slower but better results',
ultra: 'Maximum quality with 80 steps, very slow but best results'
}
return (descriptions as any)[this.value] || 'Unknown preset'
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo with complex option objects:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="quality-preset"
/>
<div style="margin-top: 12px; padding: 8px; background: rgba(0,0,0,0.05); border-radius: 4px;">
<div style="font-size: 12px; font-weight: 600; color: #374151;">{{ presetDescription }}</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complex combo FormItem with object options showing text/value pairs and descriptions.'
}
}
}
}

View File

@@ -21,7 +21,7 @@
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:model-value="formValue"
v-model:modelValue="formValue"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
/>

View File

@@ -0,0 +1,566 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import InputKnob from './InputKnob.vue'
const meta: Meta<typeof InputKnob> = {
title: 'Components/Common/InputKnob',
component: InputKnob,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'InputKnob combines a PrimeVue Knob and InputNumber for dual input methods. It features value synchronization, range validation, step constraints, and automatic decimal precision handling based on step values.'
}
}
},
argTypes: {
modelValue: {
control: { type: 'number' },
description: 'Current numeric value (v-model)',
defaultValue: 50
},
min: {
control: { type: 'number' },
description: 'Minimum allowed value',
defaultValue: 0
},
max: {
control: { type: 'number' },
description: 'Maximum allowed value',
defaultValue: 100
},
step: {
control: { type: 'number', step: 0.01 },
description: 'Step increment for both knob and input',
defaultValue: 1
},
resolution: {
control: { type: 'number', min: 0, max: 5 },
description:
'Number of decimal places to display (auto-calculated from step if not provided)',
defaultValue: undefined
},
inputClass: {
control: 'text',
description: 'Additional CSS classes for the number input',
defaultValue: undefined
},
knobClass: {
control: 'text',
description: 'Additional CSS classes for the knob',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof InputKnob>
export const Default: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 50
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Current Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
:inputClass="args.inputClass"
:knobClass="args.knobClass"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 50,
min: 0,
max: 100,
step: 1
},
parameters: {
docs: {
description: {
story:
'Default InputKnob with range 0-100 and step of 1. Use either the knob or number input to change the value.'
}
}
}
}
export const DecimalPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 2.5
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Decimal value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Precision Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 2.5,
min: 0,
max: 10,
step: 0.1
},
parameters: {
docs: {
description: {
story:
'InputKnob with decimal step (0.1) - automatically shows one decimal place based on step precision.'
}
}
}
}
export const HighPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 1.234
}
},
methods: {
handleUpdate(newValue: number) {
console.log('High precision value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>High Precision: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 1.234,
min: 0,
max: 5,
step: 0.001,
resolution: 3
},
parameters: {
docs: {
description: {
story:
'High precision InputKnob with step of 0.001 and 3 decimal places resolution.'
}
}
}
}
export const LargeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 500
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Large range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Large Range Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 500,
min: 0,
max: 1000,
step: 10
},
parameters: {
docs: {
description: {
story:
'InputKnob with large range (0-1000) and step of 10 for coarser control.'
}
}
}
}
export const NegativeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 0
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Negative range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Negative Range: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 0,
min: -50,
max: 50,
step: 5
},
parameters: {
docs: {
description: {
story:
'InputKnob with negative range (-50 to 50) demonstrating bidirectional control.'
}
}
}
}
// ComfyUI specific examples
export const CFGScale: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
cfgScale: 7.5
}
},
methods: {
updateCFG(value: number) {
console.log('CFG Scale updated:', value)
this.cfgScale = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
CFG Scale
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Controls how closely the model follows the prompt
</div>
<InputKnob
:modelValue="cfgScale"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="updateCFG"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ cfgScale }} (Recommended: 6-8)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI CFG Scale parameter example - common parameter for controlling prompt adherence.'
}
}
}
}
export const SamplingSteps: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
steps: 20
}
},
methods: {
updateSteps(value: number) {
console.log('Sampling steps updated:', value)
this.steps = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Sampling Steps
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Number of denoising steps for image generation
</div>
<InputKnob
:modelValue="steps"
:min="1"
:max="150"
:step="1"
@update:modelValue="updateSteps"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ steps }} (Higher = better quality, slower)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Sampling Steps parameter example - controls generation quality vs speed.'
}
}
}
}
export const DenoiseStrength: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
denoise: 1.0
}
},
methods: {
updateDenoise(value: number) {
console.log('Denoise strength updated:', value)
this.denoise = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Denoise Strength
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
How much noise to add (1.0 = complete denoising)
</div>
<InputKnob
:modelValue="denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="updateDenoise"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ denoise }} (0.0 = no change, 1.0 = full generation)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Denoise Strength parameter example - high precision control for img2img workflows.'
}
}
}
}
export const CustomStyling: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
value: 75
}
},
methods: {
updateValue(newValue: number) {
console.log('Custom styled value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: 600;">
Custom Styled InputKnob
</div>
<InputKnob
:modelValue="value"
:min="0"
:max="100"
:step="1"
inputClass="custom-input"
knobClass="custom-knob"
@update:modelValue="updateValue"
/>
<style>
.custom-input {
font-weight: bold;
color: #2563eb;
}
.custom-knob {
transform: scale(1.2);
}
</style>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'InputKnob with custom CSS classes applied to both knob and input components.'
}
}
}
}
// Gallery showing different parameter types
export const ParameterGallery: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
params: {
cfg: 7.5,
steps: 20,
denoise: 1.0,
temperature: 0.8
}
}
},
methods: {
updateParam(param: string, value: number) {
console.log(`${param} updated:`, value)
;(this.params as any)[param] = value
}
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 20px; max-width: 600px;">
<div>
<div style="font-weight: 600; margin-bottom: 8px;">CFG Scale</div>
<InputKnob
:modelValue="params.cfg"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="(v) => updateParam('cfg', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Steps</div>
<InputKnob
:modelValue="params.steps"
:min="1"
:max="100"
:step="1"
@update:modelValue="(v) => updateParam('steps', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Denoise</div>
<InputKnob
:modelValue="params.denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="(v) => updateParam('denoise', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Temperature</div>
<InputKnob
:modelValue="params.temperature"
:min="0"
:max="2"
:step="0.1"
@update:modelValue="(v) => updateParam('temperature', v)"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different parameter types commonly used in ComfyUI workflows.'
}
}
}
}

View File

@@ -0,0 +1,256 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NoResultsPlaceholder from './NoResultsPlaceholder.vue'
const meta: Meta<typeof NoResultsPlaceholder> = {
title: 'Components/Common/NoResultsPlaceholder',
component: NoResultsPlaceholder,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'NoResultsPlaceholder displays an empty state with optional icon, title, message, and action button. Built with PrimeVue Card component and customizable styling.'
}
}
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes to apply to the wrapper',
defaultValue: undefined
},
icon: {
control: 'text',
description: 'PrimeIcons icon class to display',
defaultValue: undefined
},
title: {
control: 'text',
description: 'Main heading text',
defaultValue: 'No Results'
},
message: {
control: 'text',
description: 'Descriptive message text (supports multi-line with \\n)',
defaultValue: 'No items found'
},
textClass: {
control: 'text',
description: 'Additional CSS classes for the message text',
defaultValue: undefined
},
buttonLabel: {
control: 'text',
description: 'Label for action button (button hidden if not provided)',
defaultValue: undefined
},
onAction: {
action: 'action',
description: 'Event emitted when action button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof NoResultsPlaceholder>
export const Default: Story = {
args: {
title: 'No Results',
message: 'No items found'
},
parameters: {
docs: {
description: {
story: 'Basic placeholder with just title and message.'
}
}
}
}
export const WithIcon: Story = {
args: {
icon: 'pi pi-search',
title: 'No Search Results',
message: 'Try adjusting your search criteria or filters'
},
parameters: {
docs: {
description: {
story:
'Placeholder with a search icon to indicate empty search results.'
}
}
}
}
export const WithActionButton: Story = {
args: {
icon: 'pi pi-plus',
title: 'No Items',
message: 'Get started by creating your first item',
buttonLabel: 'Create Item'
},
parameters: {
docs: {
description: {
story:
'Placeholder with an action button to help users take the next step.'
}
}
}
}
export const MultilineMessage: Story = {
args: {
icon: 'pi pi-exclamation-triangle',
title: 'Connection Error',
message:
'Unable to load data from the server.\nPlease check your internet connection\nand try again.',
buttonLabel: 'Retry'
},
parameters: {
docs: {
description: {
story: 'Placeholder with multi-line message using newline characters.'
}
}
}
}
export const EmptyWorkflow: Story = {
args: {
icon: 'pi pi-sitemap',
title: 'No Workflows',
message:
'Create your first ComfyUI workflow to get started with image generation',
buttonLabel: 'New Workflow'
},
parameters: {
docs: {
description: {
story: 'Example for empty workflow state in ComfyUI context.'
}
}
}
}
export const EmptyModels: Story = {
args: {
icon: 'pi pi-download',
title: 'No Models Found',
message:
'Download models from the model manager to start generating images',
buttonLabel: 'Open Model Manager'
},
parameters: {
docs: {
description: {
story: 'Example for empty models state with download action.'
}
}
}
}
export const FilteredResults: Story = {
args: {
icon: 'pi pi-filter',
title: 'No Matching Results',
message:
'No items match your current filters.\nTry clearing some filters to see more results.',
buttonLabel: 'Clear Filters'
},
parameters: {
docs: {
description: {
story: 'Placeholder for filtered results with option to clear filters.'
}
}
}
}
export const CustomStyling: Story = {
args: {
class: 'custom-placeholder',
icon: 'pi pi-star',
title: 'No Favorites',
message: 'Mark items as favorites to see them here',
textClass: 'text-muted-foreground',
buttonLabel: 'Browse Items'
},
parameters: {
docs: {
description: {
story: 'Placeholder with custom CSS classes applied.'
}
}
}
}
// Interactive story to test action event
export const Interactive: Story = {
args: {
icon: 'pi pi-cog',
title: 'Configuration Required',
message: 'Complete the setup to continue',
buttonLabel: 'Configure'
},
parameters: {
docs: {
description: {
story:
'Interactive placeholder - click the button to see the action event in the Actions panel.'
}
}
}
}
// Gallery view showing different icon options
export const IconGallery: Story = {
render: () => ({
components: { NoResultsPlaceholder },
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; padding: 20px;">
<NoResultsPlaceholder
icon="pi pi-search"
title="Search"
message="No search results"
/>
<NoResultsPlaceholder
icon="pi pi-inbox"
title="Empty Inbox"
message="No messages"
/>
<NoResultsPlaceholder
icon="pi pi-heart"
title="No Favorites"
message="No favorite items"
/>
<NoResultsPlaceholder
icon="pi pi-folder-open"
title="Empty Folder"
message="This folder is empty"
/>
<NoResultsPlaceholder
icon="pi pi-shopping-cart"
title="Empty Cart"
message="Your cart is empty"
/>
<NoResultsPlaceholder
icon="pi pi-users"
title="No Users"
message="No users found"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing different icon options and use cases.'
}
}
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import RefreshButton from './RefreshButton.vue'
const meta: Meta<typeof RefreshButton> = {
title: 'Components/Common/RefreshButton',
component: RefreshButton,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'RefreshButton is an interactive button with loading state management. It shows a refresh icon that transforms into a progress spinner when active, using v-model for state control.'
}
}
},
argTypes: {
modelValue: {
control: 'boolean',
description: 'Active/loading state of the button (v-model)'
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled'
},
outlined: {
control: 'boolean',
description: 'Whether to use outlined button style'
},
severity: {
control: 'select',
options: ['secondary', 'success', 'info', 'warn', 'help', 'danger'],
description: 'PrimeVue severity level for button styling'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof RefreshButton>
const createStoryRender =
(initialState = false, asyncDuration = 2000) =>
(args: any) => ({
components: { RefreshButton },
setup() {
const isActive = ref(args.modelValue ?? initialState)
const actions = ref<string[]>([])
const logAction = (action: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(`${action} (${timestamp})`)
if (actions.value.length > 5) actions.value.pop()
console.log(action)
}
const handleRefresh = async () => {
logAction('Refresh started')
isActive.value = true
await new Promise((resolve) => setTimeout(resolve, asyncDuration))
isActive.value = false
logAction('Refresh completed')
}
return { args, isActive, actions, handleRefresh }
},
template: `
<div style="padding: 20px;">
<RefreshButton
v-model="isActive"
v-bind="args"
@refresh="handleRefresh"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createStoryRender(),
args: {
modelValue: false,
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Active: Story = {
render: createStoryRender(true),
args: {
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Disabled: Story = {
render: createStoryRender(),
args: {
disabled: true,
outlined: true,
severity: 'secondary'
}
}
export const Filled: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: false,
severity: 'secondary'
}
}
export const SuccessSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'success'
}
}
export const DangerSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'danger'
}
}
// Simplified gallery showing all severities
export const SeverityGallery: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const severities = [
'secondary',
'success',
'info',
'warn',
'help',
'danger'
]
const states = ref(Object.fromEntries(severities.map((s) => [s, false])))
const refresh = async (severity: string) => {
console.log(`Refreshing with ${severity} severity`)
states.value[severity] = true
await new Promise((resolve) => setTimeout(resolve, 2000))
states.value[severity] = false
}
return { severities, states, refresh }
},
template: `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 20px;">
<div v-for="severity in severities" :key="severity" style="text-align: center;">
<RefreshButton
v-model="states[severity]"
:severity="severity"
@refresh="refresh(severity)"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666; text-transform: capitalize;">
{{ severity }}
</div>
</div>
</div>
`
})
}
// ComfyUI usage examples
export const WorkflowRefresh: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const isRefreshing = ref(false)
const refreshWorkflows = async () => {
console.log('Refreshing workflows...')
isRefreshing.value = true
await new Promise((resolve) => setTimeout(resolve, 3000))
isRefreshing.value = false
console.log('Workflows refreshed!')
}
return { isRefreshing, refreshWorkflows }
},
template: `
<div style="display: flex; align-items: center; gap: 12px; padding: 20px;">
<span>Workflows:</span>
<RefreshButton v-model="isRefreshing" @refresh="refreshWorkflows" />
</div>
`
})
}

View File

@@ -0,0 +1,265 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta = {
title: 'Components/Common/SearchBox',
component: SearchBox as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchBox provides a comprehensive search interface with debounced input, active filter chips, and optional filter button. Features automatic clear functionality and sophisticated event handling for search workflows.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'Current search query text (v-model)'
},
placeholder: {
control: 'text',
description: 'Placeholder text for the search input'
},
icon: {
control: 'text',
description: 'PrimeIcons icon class for the search icon'
},
debounceTime: {
control: { type: 'number', min: 0, max: 1000, step: 50 },
description: 'Debounce delay in milliseconds for search events'
},
filterIcon: {
control: 'text',
description: 'Optional filter button icon (button hidden if not provided)'
},
filters: {
control: 'object',
description: 'Array of active filter chips to display'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
const createSearchBoxRender =
(initialFilters: any[] = []) =>
(args: any) => ({
components: { SearchBox },
setup() {
const searchQuery = ref(args.modelValue || '')
const filters = ref(args.filters || initialFilters)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleUpdate = (value: string) => {
searchQuery.value = value
logAction('Search text updated', value)
}
const handleSearch = (value: string, searchFilters: any[]) => {
logAction(
'Debounced search',
`"${value}" with ${searchFilters.length} filters`
)
}
const handleShowFilter = () => {
logAction('Filter button clicked')
}
const handleRemoveFilter = (filter: any) => {
const index = filters.value.findIndex((f: any) => f === filter)
if (index > -1) {
filters.value.splice(index, 1)
logAction('Filter removed', filter.label || filter)
}
}
return {
args,
searchQuery,
filters,
actions,
handleUpdate,
handleSearch,
handleShowFilter,
handleRemoveFilter
}
},
template: `
<div style="width: 400px; padding: 20px;">
<SearchBox
:modelValue="searchQuery"
v-bind="args"
:filters="filters"
@update:modelValue="handleUpdate"
@search="handleSearch"
@showFilter="handleShowFilter"
@removeFilter="handleRemoveFilter"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search nodes...',
icon: 'pi pi-search',
debounceTime: 300,
filters: []
}
}
export const WithFilters: Story = {
render: createSearchBoxRender([
{ label: 'Image', type: 'category' },
{ label: 'Sampling', type: 'category' },
{ label: 'Recent', type: 'sort' }
]),
args: {
modelValue: 'stable diffusion',
placeholder: 'Search models...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter'
}
}
export const WithFilterButton: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search workflows...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter',
filters: []
}
}
export const FastDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Fast search (50ms debounce)...',
icon: 'pi pi-search',
debounceTime: 50,
filters: []
}
}
export const SlowDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Slow search (1000ms debounce)...',
icon: 'pi pi-search',
debounceTime: 1000,
filters: []
}
}
// ComfyUI examples
export const NodeSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('')
const nodeFilters = ref([
{ label: 'Sampling', type: 'category' },
{ label: 'Popular', type: 'sort' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching nodes:', { value, filters })
}
const handleRemoveFilter = (filter: any) => {
const index = nodeFilters.value.findIndex((f) => f === filter)
if (index > -1) {
nodeFilters.value.splice(index, 1)
}
}
return {
searchQuery,
nodeFilters,
handleSearch,
handleRemoveFilter
}
},
template: `
<div style="width: 300px;">
<div style="margin-bottom: 8px; font-weight: 600;">Node Library</div>
<SearchBox
v-model="searchQuery"
placeholder="Search nodes..."
icon="pi pi-box"
:debounceTime="300"
filterIcon="pi pi-filter"
:filters="nodeFilters"
@search="handleSearch"
@removeFilter="handleRemoveFilter"
/>
</div>
`
})
}
export const ModelSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('stable-diffusion')
const modelFilters = ref([
{ label: 'SDXL', type: 'version' },
{ label: 'Checkpoints', type: 'type' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching models:', { value, filters })
}
return {
searchQuery,
modelFilters,
handleSearch
}
},
template: `
<div style="width: 350px;">
<div style="margin-bottom: 8px; font-weight: 600;">Model Manager</div>
<SearchBox
v-model="searchQuery"
placeholder="Search models..."
icon="pi pi-database"
:debounceTime="400"
filterIcon="pi pi-sliders-h"
:filters="modelFilters"
@search="handleSearch"
/>
</div>
`
})
}

View File

@@ -0,0 +1,279 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SearchFilterChip from './SearchFilterChip.vue'
const meta: Meta<typeof SearchFilterChip> = {
title: 'Components/Common/SearchFilterChip',
component: SearchFilterChip,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchFilterChip displays a removable chip with a badge and text, commonly used for showing active filters in search interfaces. Built with PrimeVue Chip and Badge components.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Main text content displayed on the chip',
defaultValue: 'Filter'
},
badge: {
control: 'text',
description: 'Badge text/number displayed before the main text',
defaultValue: '1'
},
badgeClass: {
control: 'select',
options: ['i-badge', 'o-badge', 'c-badge', 's-badge'],
description:
'CSS class for badge styling (i-badge: green, o-badge: red, c-badge: blue, s-badge: yellow)',
defaultValue: 'i-badge'
},
onRemove: {
description: 'Event emitted when the chip remove button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof SearchFilterChip>
export const Default: Story = {
args: {
text: 'Active Filter',
badge: '5',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Default search filter chip with green badge.'
}
}
}
}
export const InputBadge: Story = {
args: {
text: 'Inputs',
badge: '3',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with green input badge (i-badge class).'
}
}
}
}
export const OutputBadge: Story = {
args: {
text: 'Outputs',
badge: '2',
badgeClass: 'o-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with red output badge (o-badge class).'
}
}
}
}
export const CategoryBadge: Story = {
args: {
text: 'Category',
badge: '8',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with blue category badge (c-badge class).'
}
}
}
}
export const StatusBadge: Story = {
args: {
text: 'Status',
badge: '12',
badgeClass: 's-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with yellow status badge (s-badge class).'
}
}
}
}
export const LongText: Story = {
args: {
text: 'Very Long Filter Name That Might Wrap',
badge: '999+',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Filter chip with long text and large badge number to test layout.'
}
}
}
}
export const SingleCharacterBadge: Story = {
args: {
text: 'Model Type',
badge: 'A',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with single character badge.'
}
}
}
}
export const ComfyUIFilters: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 20px;">
<SearchFilterChip
text="Sampling Nodes"
badge="5"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Image Outputs"
badge="3"
badgeClass="o-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Conditioning"
badge="12"
badgeClass="c-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Advanced"
badge="7"
badgeClass="s-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="SDXL Models"
badge="24"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="ControlNet"
badge="8"
badgeClass="o-badge"
@remove="handleRemove"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Example showing multiple filter chips as they might appear in ComfyUI search interface.'
}
}
}
}
export const Interactive: Story = {
args: {
text: 'Removable Filter',
badge: '42',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Interactive chip - click the X button to see the remove event in the Actions panel.'
}
}
}
}
// Gallery showing all badge styles
export const BadgeStyleGallery: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 20px; max-width: 400px;">
<div style="text-align: center;">
<SearchFilterChip
text="Input Badge"
badge="I"
badgeClass="i-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Green (i-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Output Badge"
badge="O"
badgeClass="o-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Red (o-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Category Badge"
badge="C"
badgeClass="c-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Blue (c-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Status Badge"
badge="S"
badgeClass="s-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Yellow (s-badge)</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing all available badge styles and their colors.'
}
}
}
}

View File

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

View File

@@ -0,0 +1,250 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextDivider from './TextDivider.vue'
const meta: Meta<typeof TextDivider> = {
title: 'Components/Common/TextDivider',
component: TextDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TextDivider combines text with a PrimeVue divider to create labeled section separators. The text can be positioned on either side of the divider line with various styling options.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Text content to display alongside the divider',
defaultValue: 'Section'
},
position: {
control: 'select',
options: ['left', 'right'],
description: 'Position of text relative to the divider',
defaultValue: 'left'
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'top', 'bottom'],
description: 'Alignment of the divider line',
defaultValue: 'center'
},
type: {
control: 'select',
options: ['solid', 'dashed', 'dotted'],
description: 'Style of the divider line',
defaultValue: 'solid'
},
layout: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Layout direction of the divider',
defaultValue: 'horizontal'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof TextDivider>
export const Default: Story = {
args: {
text: 'Section Title',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Default text divider with text on the left side of a solid horizontal line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const RightPosition: Story = {
args: {
text: 'Section Title',
position: 'right',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with text positioned on the right side of the line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DashedStyle: Story = {
args: {
text: 'Dashed Section',
position: 'left',
align: 'center',
type: 'dashed',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dashed line style for a softer visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DottedStyle: Story = {
args: {
text: 'Dotted Section',
position: 'right',
align: 'center',
type: 'dotted',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dotted line style for subtle content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const VerticalLayout: Story = {
args: {
text: 'Vertical',
position: 'left',
align: 'center',
type: 'solid',
layout: 'vertical'
},
parameters: {
docs: {
description: {
story:
'Text divider in vertical layout for side-by-side content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const LongText: Story = {
args: {
text: 'Configuration Settings and Options',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with longer text content to demonstrate text wrapping and spacing behavior.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,651 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import TreeExplorer from './TreeExplorer.vue'
const meta: Meta = {
title: 'Components/Common/TreeExplorer',
component: TreeExplorer as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TreeExplorer provides a sophisticated tree navigation component with expandable nodes, selection, context menus, drag-and-drop support, and customizable node rendering. Features folder operations, renaming, deletion, and advanced tree manipulation capabilities.'
}
}
},
argTypes: {
root: {
control: 'object',
description: 'Root tree node with hierarchical structure'
},
expandedKeys: {
control: 'object',
description: 'Object tracking which nodes are expanded (v-model)',
defaultValue: {}
},
selectionKeys: {
control: 'object',
description: 'Object tracking which nodes are selected (v-model)',
defaultValue: {}
},
class: {
control: 'text',
description: 'Additional CSS classes for the tree',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const BasicTree: Story = {
render: (args: any) => ({
components: { TreeExplorer },
setup() {
return { args }
},
data() {
return {
expanded: {},
selected: {},
treeData: {
key: 'root',
label: 'Root',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'portrait',
label: 'Portrait Generation.json',
icon: 'pi pi-file'
},
{
key: 'landscape',
label: 'Landscape SDXL.json',
icon: 'pi pi-file'
},
{ key: 'anime', label: 'Anime Style.json', icon: 'pi pi-file' }
]
},
{
key: 'models',
label: 'Models',
icon: 'pi pi-download',
children: [
{
key: 'checkpoints',
label: 'Checkpoints',
icon: 'pi pi-folder',
children: [
{
key: 'sdxl',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'sd15',
label: 'SD_1.5.safetensors',
icon: 'pi pi-file'
}
]
},
{
key: 'lora',
label: 'LoRA',
icon: 'pi pi-folder',
children: [
{
key: 'portrait_lora',
label: 'portrait_enhance.safetensors',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'outputs',
label: 'Outputs',
icon: 'pi pi-images',
children: [
{
key: 'output1',
label: 'ComfyUI_00001_.png',
icon: 'pi pi-image'
},
{
key: 'output2',
label: 'ComfyUI_00002_.png',
icon: 'pi pi-image'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Node clicked:', node.label)
},
handleNodeDelete(node: any) {
console.log('Node delete requested:', node.label)
},
handleContextMenu(node: any, _event: MouseEvent) {
console.log('Context menu on node:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI File Explorer</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Navigate through workflows, models, and outputs
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 400px; overflow: auto;">
<TreeExplorer
:root="treeData"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Expanded: {{ Object.keys(expanded).length }} | Selected: {{ Object.keys(selected).length }}
</div>
</div>
`
}),
args: {
expandedKeys: { workflows: true, models: true },
selectionKeys: { portrait: true }
},
parameters: {
docs: {
description: {
story:
'Basic TreeExplorer with ComfyUI file structure showing workflows, models, and outputs.'
}
}
}
}
export const EmptyTree: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: {},
selected: {},
emptyTree: {
key: 'empty-root',
label: 'Empty Workspace',
children: []
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, event: MouseEvent) {
console.log('Empty tree node clicked:', node, event)
}
},
template: `
<div style="padding: 20px; width: 350px; height: 300px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Empty Workspace</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Empty tree explorer state
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 200px; display: flex; align-items: center; justify-content: center;">
<TreeExplorer
:root="emptyTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
<div style="color: #9ca3af; font-style: italic; text-align: center;">
<i class="pi pi-folder-open" style="display: block; font-size: 24px; margin-bottom: 8px;"></i>
No items in workspace
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Empty TreeExplorer showing the state when no items are present in the workspace.'
}
}
}
}
export const DeepHierarchy: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true, 'stable-diffusion': true },
selected: {},
deepTree: {
key: 'root',
label: 'Projects',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'stable-diffusion',
label: 'Stable Diffusion',
icon: 'pi pi-folder',
children: [
{
key: 'portraits',
label: 'Portraits',
icon: 'pi pi-folder',
children: [
{
key: 'realistic',
label: 'Realistic Portrait.json',
icon: 'pi pi-file'
},
{
key: 'artistic',
label: 'Artistic Portrait.json',
icon: 'pi pi-file'
}
]
},
{
key: 'landscapes',
label: 'Landscapes',
icon: 'pi pi-folder',
children: [
{
key: 'nature',
label: 'Nature Scene.json',
icon: 'pi pi-file'
},
{
key: 'urban',
label: 'Urban Environment.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'controlnet',
label: 'ControlNet',
icon: 'pi pi-folder',
children: [
{
key: 'canny',
label: 'Canny Edge.json',
icon: 'pi pi-file'
},
{
key: 'depth',
label: 'Depth Map.json',
icon: 'pi pi-file'
}
]
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Deep tree node clicked:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Deep Hierarchy</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multi-level nested folder structure with organized workflows
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="deepTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Deep hierarchical TreeExplorer showing multi-level folder organization with workflows.'
}
}
}
}
export const InteractiveOperations: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true },
selected: {},
operationLog: [],
interactiveTree: {
key: 'root',
label: 'Interactive Workspace',
children: [
{
key: 'workflows',
label: 'My Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'workflow1',
label: 'Image Generation.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
},
{
key: 'workflow2',
label: 'Video Processing.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Clicked: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleNodeDelete(node: any) {
this.operationLog.unshift(`Delete requested: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleContextMenu(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Context menu: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
}
},
template: `
<div style="padding: 20px; width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Interactive Operations</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Click nodes, right-click for context menu, test selection behavior
</p>
</div>
<div style="display: flex; gap: 16px;">
<div style="flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="interactiveTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="flex: 1; background: rgba(0,0,0,0.05); border-radius: 8px; padding: 12px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Operation Log:</div>
<div v-if="operationLog.length === 0" style="font-style: italic; color: #9ca3af; font-size: 12px;">
No operations yet...
</div>
<div v-for="(entry, index) in operationLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Interactive TreeExplorer demonstrating click, context menu, and selection operations with live logging.'
}
}
}
}
export const WorkflowManager: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { 'workflow-library': true, 'my-workflows': true },
selected: {},
workflowTree: {
key: 'root',
label: 'Workflow Manager',
children: [
{
key: 'my-workflows',
label: 'My Workflows',
icon: 'pi pi-user',
children: [
{
key: 'draft1',
label: 'Draft - SDXL Portrait.json',
icon: 'pi pi-file-edit'
},
{
key: 'final1',
label: 'Final - Product Shots.json',
icon: 'pi pi-file'
},
{
key: 'temp1',
label: 'Temp - Testing.json',
icon: 'pi pi-clock'
}
]
},
{
key: 'workflow-library',
label: 'Workflow Library',
icon: 'pi pi-book',
children: [
{
key: 'community',
label: 'Community',
icon: 'pi pi-users',
children: [
{
key: 'popular1',
label: 'SDXL Ultimate.json',
icon: 'pi pi-star-fill'
},
{
key: 'popular2',
label: 'ControlNet Pro.json',
icon: 'pi pi-star-fill'
}
]
},
{
key: 'templates',
label: 'Templates',
icon: 'pi pi-clone',
children: [
{
key: 'template1',
label: 'Basic Generation.json',
icon: 'pi pi-file'
},
{
key: 'template2',
label: 'Img2Img Template.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'recent',
label: 'Recent',
icon: 'pi pi-history',
children: [
{
key: 'recent1',
label: 'Last Session.json',
icon: 'pi pi-clock'
},
{
key: 'recent2',
label: 'Quick Test.json',
icon: 'pi pi-clock'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Workflow selected:', node.label)
}
},
template: `
<div style="padding: 20px; width: 450px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Workflow Manager</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Organized workflow library with categories, templates, and recent files
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="workflowTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Realistic workflow manager showing organized hierarchy with categories, templates, and recent files.'
}
}
}
}
export const CompactView: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { models: true },
selected: {},
compactTree: {
key: 'root',
label: 'Models',
children: [
{
key: 'models',
label: 'Checkpoints',
icon: 'pi pi-download',
children: [
{
key: 'model1',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'model2',
label: 'SD_1.5_pruned.safetensors',
icon: 'pi pi-file'
},
{
key: 'model3',
label: 'Realistic_Vision_V5.safetensors',
icon: 'pi pi-file'
},
{
key: 'model4',
label: 'AnythingV5_v3.safetensors',
icon: 'pi pi-file'
}
]
}
]
} as TreeExplorerNode
}
},
template: `
<div style="padding: 20px; width: 300px; height: 400px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Compact Model List</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Compact view for smaller spaces
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="compactTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
class="text-sm"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Compact TreeExplorer view for smaller interface areas with minimal spacing.'
}
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
class="tree-explorer py-0 px-2 2xl:px-4"
:class="props.class"
:value="renderedRoot.children"

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UserAvatar from './UserAvatar.vue'
const meta: Meta<typeof UserAvatar> = {
title: 'Components/Common/UserAvatar',
component: UserAvatar,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'UserAvatar displays a circular avatar image with fallback to a user icon when no image is provided or when the image fails to load. Built on top of PrimeVue Avatar component.'
}
}
},
argTypes: {
photoUrl: {
control: 'text',
description:
'URL of the user photo to display. Falls back to user icon if null, undefined, or fails to load',
defaultValue: null
},
ariaLabel: {
control: 'text',
description: 'Accessibility label for screen readers',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof UserAvatar>
export const Default: Story = {
args: {
photoUrl: null,
ariaLabel: 'User avatar'
},
parameters: {
docs: {
description: {
story: 'Default avatar with no image - shows user icon fallback.'
}
}
}
}
export const WithValidImage: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'John Doe avatar'
},
parameters: {
docs: {
description: {
story: 'Avatar with a valid image URL displaying a user photo.'
}
}
}
}
export const WithBrokenImage: Story = {
args: {
photoUrl: 'https://example.com/nonexistent-image.jpg',
ariaLabel: 'User with broken image'
},
parameters: {
docs: {
description: {
story:
'Avatar with a broken image URL - automatically falls back to user icon when image fails to load.'
}
}
}
}
export const WithCustomAriaLabel: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'Sarah Johnson, Project Manager'
},
parameters: {
docs: {
description: {
story:
'Avatar with custom accessibility label for better screen reader experience.'
}
}
}
}
export const EmptyString: Story = {
args: {
photoUrl: '',
ariaLabel: 'User with empty photo URL'
},
parameters: {
docs: {
description: {
story:
'Avatar with empty string photo URL - treats empty string as no image.'
}
}
}
}
export const UndefinedUrl: Story = {
args: {
photoUrl: undefined,
ariaLabel: 'User with undefined photo URL'
},
parameters: {
docs: {
description: {
story: 'Avatar with undefined photo URL - shows default user icon.'
}
}
}
}
// Gallery view showing different states
export const Gallery: Story = {
render: () => ({
components: { UserAvatar },
template: `
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center; padding: 20px;">
<div style="text-align: center;">
<UserAvatar :photoUrl="null" ariaLabel="No image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">No Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face" ariaLabel="Valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Valid Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face" ariaLabel="Another valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Another Valid</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://example.com/broken.jpg" ariaLabel="Broken image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Broken URL</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="" ariaLabel="Empty string" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Empty String</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different avatar states side by side for comparison.'
}
}
}
}

View File

@@ -138,7 +138,7 @@ const allMissingNodesInstalled = computed(() => {
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled && showInstallAllButton.value) {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()

View File

@@ -16,7 +16,7 @@
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selected-tab="selectedTab"
v-model:selectedTab="selectedTab"
:tabs="tabs"
/>
<div
@@ -57,9 +57,9 @@
</IconButton>
</div>
<RegistrySearchBar
v-model:search-query="searchQuery"
v-model:search-mode="searchMode"
v-model:sort-field="sortField"
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"

View File

@@ -8,7 +8,7 @@
</template>
<script setup lang="ts">
const { value = 'N/A', label } = defineProps<{
const { value = 'N/A', label = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()

View File

@@ -41,12 +41,12 @@
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:model-value="searchMode"
v-model:modelValue="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:model-value="sortField"
v-model:modelValue="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>

View File

@@ -4,7 +4,7 @@
class="px-4 py-2 flex items-center"
>
<TabMenu
v-model:active-index="activeTabIndex"
v-model:activeIndex="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{

View File

@@ -34,7 +34,7 @@ const updateWidgets = () => {
const widget = widgetState.widget
// Early exit for non-visible widgets
if (!widget.isVisible() || !widgetState.active) {
if (!widget.isVisible()) {
widgetState.visible = false
continue
}

View File

@@ -7,7 +7,7 @@
severity="secondary"
text
data-testid="bypass-button"
class="hover:dark-theme:bg-charcoal-600 hover:bg-[#E7E6E6]"
class="hover:dark-theme:bg-charcoal-300 hover:bg-[#E7E6E6]"
@click="toggleBypass"
>
<template #icon>

View File

@@ -59,7 +59,7 @@ import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.
import { ComponentWidget } from '@/scripts/domWidget'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const { widget, history } = defineProps<{
const { widget, history = '[]' } = defineProps<{
widget?: ComponentWidget<string>
history: string
}>()

View File

@@ -124,43 +124,50 @@ watch(
}
}
)
useEventListener(document, 'mousedown', (event) => {
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
return
}
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
onMounted(() => {
if (!isDOMWidget(widget)) {
return
// Set up event listeners only after the widget is mounted and visible
const setupDOMEventListeners = () => {
if (!isDOMWidget(widget) || !widgetState.visible) return
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
}
useEventListener(
widget.element,
widget.options.selectOn ?? ['focus', 'click'],
() => {
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
useEventListener(widget.element, evt, () => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
})
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
}
)
})
},
{ immediate: true }
)
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) {
return
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
// Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
}
// Only append if not already a child
if (widgetElement.value.contains(widget.element)) {
return
}
widgetElement.value.appendChild(widget.element)
}
// Check on mount - but only after next tick to ensure visibility is calculated

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</template>
<template #header>
<SearchBox
v-model:model-value="searchQuery"
v-model:modelValue="searchQuery"
class="model-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchModels') + '...'"
@search="handleSearch"
@@ -31,7 +31,7 @@
<ElectronDownloadItems v-if="isElectron()" />
<TreeExplorer
v-model:expanded-keys="expandedKeys"
v-model:expandedKeys="expandedKeys"
class="model-lib-tree-explorer"
:root="renderedRoot"
>

View File

@@ -78,7 +78,7 @@
<template #header>
<div>
<SearchBox
v-model:model-value="searchQuery"
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
@@ -106,7 +106,7 @@
class="m-2"
/>
<TreeExplorer
v-model:expanded-keys="expandedKeys"
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>

View File

@@ -86,7 +86,7 @@
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
v-model:active-index="galleryActiveIndex"
v-model:activeIndex="galleryActiveIndex"
:all-gallery-items="allGalleryItems"
/>
</template>

View File

@@ -14,7 +14,7 @@
</template>
<template #header>
<SearchBox
v-model:model-value="searchQuery"
v-model:modelValue="searchQuery"
class="workflows-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchWorkflows') + '...'"
@search="handleSearch"
@@ -32,7 +32,7 @@
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
v-model:expandedKeys="dummyExpandedKeys"
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selection-keys="selectionKeys"
>
@@ -74,7 +74,7 @@
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
v-model:expandedKeys="dummyExpandedKeys"
:root="
renderTreeNode(
bookmarkedWorkflowsTree,
@@ -96,7 +96,7 @@
/>
<TreeExplorer
v-if="workflowStore.persistedWorkflows.length > 0"
v-model:expanded-keys="expandedKeys"
v-model:expandedKeys="expandedKeys"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
:selection-keys="selectionKeys"
>
@@ -114,7 +114,7 @@
</div>
<div v-else class="comfyui-workflows-search-panel">
<TreeExplorer
v-model:expanded-keys="expandedKeys"
v-model:expandedKeys="expandedKeys"
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
>
<template #node="{ node }">

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -7,7 +6,6 @@ import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthUserInfo } from '@/types/authTypes'
export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
@@ -22,27 +20,6 @@ export const useCurrentUser = () => {
() => !!isApiKeyLogin.value || firebaseUser.value !== null
)
const resolvedUserInfo = computed<AuthUserInfo | null>(() => {
if (isApiKeyLogin.value && apiKeyStore.currentUser) {
return { id: apiKeyStore.currentUser.id }
}
if (firebaseUser.value) {
return { id: firebaseUser.value.uid }
}
return null
})
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
if (resolvedUserInfo.value) {
callback(resolvedUserInfo.value)
}
const stop = whenever(resolvedUserInfo, callback)
return () => stop()
}
const userDisplayName = computed(() => {
if (isApiKeyLogin.value) {
return apiKeyStore.currentUser?.name
@@ -135,10 +112,8 @@ export const useCurrentUser = () => {
userPhotoUrl,
providerName,
providerIcon,
resolvedUserInfo,
handleSignOut,
handleSignIn,
handleDeleteAccount,
onUserResolved
handleDeleteAccount
}
}

View File

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

View File

@@ -102,8 +102,6 @@ export function useSelectionToolboxPosition(
worldPosition.value = {
x: unionBounds.x + unionBounds.width / 2,
// createBounds() applied a default padding of 10px
// so adjust Y to maintain visual consistency
y: unionBounds.y - 10
}

View File

@@ -1,10 +1,6 @@
import { useElementBounding } from '@vueuse/core'
import type { LGraphCanvas, Point } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
let sharedConverter: ReturnType<typeof useCanvasPositionConversion> | null =
null
import type { LGraphCanvas, Vector2 } from '@/lib/litegraph/src/litegraph'
/**
* Convert between canvas and client positions
@@ -18,7 +14,7 @@ export const useCanvasPositionConversion = (
) => {
const { left, top, update } = useElementBounding(canvasElement)
const clientPosToCanvasPos = (pos: Point): Point => {
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] - left.value) / scale - offset[0],
@@ -26,7 +22,7 @@ export const useCanvasPositionConversion = (
]
}
const canvasPosToClientPos = (pos: Point): Point => {
const canvasPosToClientPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
return [
(pos[0] + offset[0]) * scale + left.value,
@@ -40,10 +36,3 @@ export const useCanvasPositionConversion = (
update
}
}
export function useSharedCanvasPositionConversion() {
if (sharedConverter) return sharedConverter
const lgCanvas = useCanvasStore().getCanvas()
sharedConverter = useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
return sharedConverter
}

View File

@@ -169,74 +169,6 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
}
// ---- constants ----
const SORA_SIZES = {
BASIC: new Set(['720x1280', '1280x720']),
PRO: new Set(['1024x1792', '1792x1024'])
}
const ALL_SIZES = new Set([...SORA_SIZES.BASIC, ...SORA_SIZES.PRO])
// ---- sora-2 pricing helpers ----
function validateSora2Selection(
modelRaw: string,
duration: number,
sizeRaw: string
): string | undefined {
const model = modelRaw?.toLowerCase() ?? ''
const size = sizeRaw?.toLowerCase() ?? ''
if (!duration || Number.isNaN(duration)) return 'Set duration (4s / 8s / 12s)'
if (!size) return 'Set size (720x1280, 1280x720, 1024x1792, 1792x1024)'
if (!ALL_SIZES.has(size))
return 'Invalid size. Must be 720x1280, 1280x720, 1024x1792, or 1792x1024.'
if (model.includes('sora-2-pro')) return undefined
if (model.includes('sora-2') && !SORA_SIZES.BASIC.has(size))
return 'sora-2 supports only 720x1280 or 1280x720'
if (!model.includes('sora-2')) return 'Unsupported model'
return undefined
}
function perSecForSora2(modelRaw: string, sizeRaw: string): number {
const model = modelRaw?.toLowerCase() ?? ''
const size = sizeRaw?.toLowerCase() ?? ''
if (model.includes('sora-2-pro')) {
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.3
}
if (model.includes('sora-2')) return 0.1
return SORA_SIZES.PRO.has(size) ? 0.5 : 0.1
}
function formatRunPrice(perSec: number, duration: number) {
return `$${(perSec * duration).toFixed(2)}/Run`
}
// ---- pricing calculator ----
const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
const getWidgetValue = (name: string) =>
String(node.widgets?.find((w) => w.name === name)?.value ?? '')
const model = getWidgetValue('model')
const size = getWidgetValue('size')
const duration = Number(
node.widgets?.find((w) => ['duration', 'duration_s'].includes(w.name))
?.value
)
if (!model || !size || !duration) return 'Set model, duration & size'
const validationError = validateSora2Selection(model, duration, size)
if (validationError) return validationError
const perSec = perSecForSora2(model, size)
return formatRunPrice(perSec, duration)
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -263,9 +195,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
IdeogramV1: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
@@ -1619,74 +1548,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
ByteDanceImageReferenceNode: {
displayPrice: byteDanceVideoPricingCalculator
},
WanTextToVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
const seconds = parseFloat(String(durationWidget.value))
const resolutionStr = String(resolutionWidget.value).toLowerCase()
const resKey = resolutionStr.includes('1080')
? '1080p'
: resolutionStr.includes('720')
? '720p'
: resolutionStr.includes('480')
? '480p'
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
const pricePerSecond: Record<string, number> = {
'480p': 0.05,
'720p': 0.1,
'1080p': 0.15
}
const pps = pricePerSecond[resKey]
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
const cost = (pps * seconds).toFixed(2)
return `$${cost}/Run`
}
},
WanImageToVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
const seconds = parseFloat(String(durationWidget.value))
const resolution = String(resolutionWidget.value).trim().toLowerCase()
const pricePerSecond: Record<string, number> = {
'480p': 0.05,
'720p': 0.1,
'1080p': 0.15
}
const pps = pricePerSecond[resolution]
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
const cost = (pps * seconds).toFixed(2)
return `$${cost}/Run`
}
},
WanTextToImageApi: {
displayPrice: '$0.03/Run'
},
WanImageToImageApi: {
displayPrice: '$0.03/Run'
}
}
@@ -1728,7 +1589,6 @@ export const useNodePricing = () => {
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIVideoSora2: ['model', 'size', 'duration'],
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
@@ -1787,9 +1647,7 @@ export const useNodePricing = () => {
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution']
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution']
}
return widgetMap[nodeType] || []
}

View File

@@ -1,6 +1,5 @@
import { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -28,19 +27,16 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
const pos = [...basePos]
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
@@ -77,7 +73,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
}
} else if (node.data instanceof ComfyWorkflow) {
const workflow = node.data
await workflowService.insertWorkflow(workflow, { position: basePos })
const position = comfyApp.clientPosToCanvasPos([
loc.clientX,
loc.clientY
])
await workflowService.insertWorkflow(workflow, { position })
}
}
}

View File

@@ -42,12 +42,7 @@ export function useManagerState() {
)
// Check command line args first (highest priority)
// --enable-manager flag enables the manager (opposite of old --disable-manager)
const hasEnableManager =
systemStats.value?.system?.argv?.includes('--enable-manager')
// If --enable-manager is NOT present, manager is disabled
if (!hasEnableManager) {
if (systemStats.value?.system?.argv?.includes('--disable-manager')) {
return ManagerUIState.DISABLED
}

View File

@@ -1,75 +0,0 @@
export interface DialogAction {
readonly label: string
readonly action: 'openUrl' | 'close' | 'cancel'
readonly url?: string
readonly severity?: 'danger' | 'primary' | 'secondary' | 'warn'
readonly returnValue: string
}
interface DesktopDialog {
readonly title: string
readonly message: string
readonly buttons: DialogAction[]
}
export const DESKTOP_DIALOGS = {
/** Shown when a corrupt venv is detected. */
reinstallVenv: {
title: 'Reinstall ComfyUI (Fresh Start)?',
message: `Sorry, we can't launch ComfyUI because some installed packages aren't compatible.
Click Reinstall to restore ComfyUI and get back up and running.
Please note: if you've added custom nodes, you'll need to reinstall them after this process.`,
buttons: [
{
label: 'Learn More',
action: 'openUrl',
url: 'https://docs.comfy.org',
returnValue: 'openDocs'
},
{
label: 'Reinstall',
action: 'close',
severity: 'danger',
returnValue: 'resetVenv'
}
]
},
/** A dialog that is shown when an invalid dialog ID is provided. */
invalidDialog: {
title: 'Invalid Dialog',
message: `Invalid dialog ID was provided.`,
buttons: [
{
label: 'Close',
action: 'cancel',
returnValue: 'cancel'
}
]
}
} as const satisfies { [K: string]: DesktopDialog }
/** The ID of a desktop dialog. */
type DesktopDialogId = keyof typeof DESKTOP_DIALOGS
/**
* Checks if {@link id} is a valid dialog ID.
* @param id The string to check
* @returns `true` if the ID is a valid dialog ID, otherwise `false`
*/
function isDialogId(id: unknown): id is DesktopDialogId {
return typeof id === 'string' && id in DESKTOP_DIALOGS
}
/**
* Gets the dialog with the given ID.
* @param dialogId The ID of the dialog to get
* @returns The dialog with the given ID
*/
export function getDialog(
dialogId: string | string[]
): DesktopDialog & { id: DesktopDialogId } {
const id = isDialogId(dialogId) ? dialogId : 'invalidDialog'
return { id, ...structuredClone(DESKTOP_DIALOGS[id]) }
}

View File

@@ -8,7 +8,7 @@ import type {
INodeOutputSlot,
ISlotType,
LLink,
Point
Vector2
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -557,7 +557,7 @@ app.registerExtension({
}
)
function isNodeAtPos(pos: Point) {
function isNodeAtPos(pos: Vector2) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true

View File

@@ -2348,7 +2348,7 @@ export class LGraphCanvas
if (
ctrlOrMeta &&
!e.altKey &&
LiteGraph.leftMouseClickBehavior === 'panning'
LiteGraph.canvasNavigationMode === 'legacy'
) {
this.#setupNodeSelectionDrag(e, pointer, node)
@@ -2616,8 +2616,8 @@ export class LGraphCanvas
!pointer.onDrag &&
this.allow_dragcanvas
) {
// allow dragging canvas based on leftMouseClickBehavior or read-only mode
if (LiteGraph.leftMouseClickBehavior === 'panning' || this.read_only) {
// allow dragging canvas if canvas is not in standard, or read-only (pan mode in standard)
if (LiteGraph.canvasNavigationMode !== 'standard' || this.read_only) {
pointer.onClick = () => this.processSelect(null, e)
pointer.finally = () => (this.dragging_canvas = false)
this.dragging_canvas = true
@@ -3629,8 +3629,8 @@ export class LGraphCanvas
e.ctrlKey || (e.metaKey && navigator.platform.includes('Mac'))
const isZoomModifier = isCtrlOrMacMeta && !e.altKey && !e.shiftKey
if (isZoomModifier || LiteGraph.mouseWheelScroll === 'zoom') {
// Zoom mode or modifier key pressed - use wheel for zoom
if (isZoomModifier || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
@@ -3645,6 +3645,7 @@ export class LGraphCanvas
this.ds.changeScale(scale, [e.clientX, e.clientY])
}
} else {
// Standard mode without ctrl - use wheel / gestures to pan
// Trackpads and mice work on significantly different scales
const factor = isTrackpad ? 0.18 : 0.008_333

View File

@@ -304,14 +304,9 @@ export class LiteGraphGlobal {
/**
* "standard": change the dragging on left mouse button click to select, enable middle-click or spacebar+left-click dragging
* "legacy": Enable dragging on left-click (original behavior)
* "custom": Use leftMouseClickBehavior and mouseWheelScroll settings
* @default "legacy"
*/
canvasNavigationMode: 'standard' | 'legacy' | 'custom' = 'legacy'
leftMouseClickBehavior: 'panning' | 'select' = 'panning'
mouseWheelScroll: 'panning' | 'zoom' = 'panning'
canvasNavigationMode: 'standard' | 'legacy' = 'legacy'
/**
* If `true`, widget labels and values will both be truncated (proportionally to size),

View File

@@ -4,14 +4,11 @@ import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink, type ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type {
INodeInputSlot,
ISlotType,
NodeId
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import {
type INodeInputSlot,
type ISlotType,
type NodeId
} from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
import { NodeOutputSlot } from '@/lib/litegraph/src/node/NodeOutputSlot'
@@ -81,10 +78,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const existingInput = this.inputs.find((i) => i.name == name)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const { inputNode } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget)
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
if (widget) this.#setWidget(subgraphInput, existingInput, widget)
return
}
const input = this.addInput(name, type)
@@ -189,14 +185,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
() => {
if (input._widget) return
const widget = subgraphInput._widget
if (!widget) return
const widgetLocator = e.detail.input.widget
this.#setWidget(subgraphInput, input, widget, widgetLocator)
this.#setWidget(subgraphInput, input, widget)
},
{ signal }
)
@@ -306,7 +301,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = resolved.inputNode.getWidgetFromSlot(resolved.input)
if (!widget) continue
this.#setWidget(subgraphInput, input, widget, resolved.input.widget)
this.#setWidget(subgraphInput, input, widget)
break
}
}
@@ -315,13 +310,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
#setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
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() {
@@ -379,9 +372,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// 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
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
Object.setPrototypeOf(input.widget, backingInput)
input._widget = promotedWidget
}

View File

@@ -45,7 +45,7 @@ export class ComboWidget
return typeof this.value === 'number' ? String(this.value) : this.value
}
private getValues(node: LGraphNode): Values {
#getValues(node: LGraphNode): Values {
const { values } = this.options
if (values == null) throw new Error('[ComboWidget]: values is required')
@@ -57,7 +57,7 @@ export class ComboWidget
* @param increment `true` if checking the use of the increment button, `false` for decrement
* @returns `true` if the value is at the given index, otherwise `false`.
*/
private canUseButton(increment: boolean): boolean {
#canUseButton(increment: boolean): boolean {
const { values } = this.options
// If using legacy duck-typed method, false is the most permissive return value
if (typeof values === 'function') return false
@@ -78,23 +78,23 @@ export class ComboWidget
* Handles edge case where the value is both the first and last item in the list.
*/
override canIncrement(): boolean {
return this.canUseButton(true)
return this.#canUseButton(true)
}
override canDecrement(): boolean {
return this.canUseButton(false)
return this.#canUseButton(false)
}
override incrementValue(options: WidgetEventOptions): void {
this.tryChangeValue(1, options)
this.#tryChangeValue(1, options)
}
override decrementValue(options: WidgetEventOptions): void {
this.tryChangeValue(-1, options)
this.#tryChangeValue(-1, options)
}
private tryChangeValue(delta: number, options: WidgetEventOptions): void {
const values = this.getValues(options.node)
#tryChangeValue(delta: number, options: WidgetEventOptions): void {
const values = this.#getValues(options.node)
const indexedValues = toArray(values)
// avoids double click event
@@ -128,7 +128,7 @@ export class ComboWidget
if (x > width - 40) return this.incrementValue({ e, node, canvas })
// Otherwise, show dropdown menu
const values = this.getValues(node)
const values = this.#getValues(node)
const values_list = toArray(values)
// Handle center click - show dropdown menu

View File

@@ -1,38 +0,0 @@
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IButtonWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget, type DrawWidgetOptions } from './BaseWidget'
class DisconnectedWidget extends BaseWidget<IButtonWidget> {
constructor(widget: IButtonWidget) {
super(widget, new LGraphNode('DisconnectedPlaceholder'))
this.disabled = true
}
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
) {
ctx.save()
this.drawWidgetShape(ctx, { width, showText })
if (showText) {
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })
}
ctx.restore()
}
override onClick() {}
override get _displayValue() {
return 'Disconnected'
}
}
const conf: IButtonWidget = {
type: 'button',
value: undefined,
name: 'Disconnected',
options: {},
y: 0,
clicked: false
}
export const disconnectedWidget = new DisconnectedWidget(conf)

View File

@@ -1,5 +1,333 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float32Array [
20,
20,
],
"_size": Float32Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
LGraph {
"_groups": [],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [],
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 0,
"lastLinkId": 0,
"lastNodeId": 0,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
LGraph {
"_groups": [

View File

@@ -122,6 +122,9 @@
"Comfy_ExportWorkflowAPI": {
"label": "تصدير سير العمل (تنسيق API)"
},
"Comfy_Feedback": {
"label": "إرسال ملاحظات"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "تحويل التحديد إلى رسم فرعي"
},
@@ -167,6 +170,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "تحميل سير العمل الافتراضي"
},
"Comfy_Manager_CustomNodesManager": {
"label": "تبديل مدير العقد المخصصة"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "تبديل شريط تقدم مدير العقد المخصصة"
},
@@ -285,4 +291,4 @@
"label": "تبديل الشريط الجانبي لسير العمل",
"tooltip": "سير العمل"
}
}
}

View File

@@ -325,6 +325,7 @@
"frontendNewer": "إصدار الواجهة الأمامية {frontendVersion} قد لا يكون متوافقاً مع الإصدار الخلفي {backendVersion}.",
"frontendOutdated": "إصدار الواجهة الأمامية {frontendVersion} قديم. يتطلب الإصدار الخلفي {requiredVersion} أو أحدث.",
"goToNode": "الانتقال إلى العقدة",
"help": "مساعدة",
"icon": "أيقونة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imageUrl": "رابط الصورة",
@@ -777,6 +778,7 @@
"File": "ملف",
"Fit Group To Contents": "ملائمة المجموعة للمحتويات",
"Focus Mode": "وضع التركيز",
"Give Feedback": "تقديم ملاحظات",
"Group Selected Nodes": "تجميع العقد المحددة",
"Help": "مساعدة",
"Help Center": "مركز المساعدة",
@@ -835,6 +837,7 @@
"Toggle Terminal Bottom Panel": "تبديل لوحة الطرفية السفلية",
"Toggle Theme (Dark/Light)": "تبديل السمة (داكن/فاتح)",
"Toggle View Controls Bottom Panel": "تبديل لوحة عناصر التحكم في العرض السفلية",
"Toggle the Custom Nodes Manager": "تبديل مدير العقد المخصصة",
"Toggle the Custom Nodes Manager Progress Bar": "تبديل شريط تقدم مدير العقد المخصصة",
"Undo": "تراجع",
"Ungroup selected group nodes": "فك تجميع عقد المجموعة المحددة",
@@ -928,6 +931,9 @@
"upscale_diffusion": "انتشار التكبير",
"upscaling": "تكبير",
"utils": "أدوات مساعدة",
"v1": "الإصدار 1",
"v2": "الإصدار 2",
"v3": "الإصدار 3",
"video": "فيديو",
"video_models": "نماذج الفيديو"
},
@@ -1687,4 +1693,4 @@
"showMinimap": "إظهار الخريطة المصغرة",
"zoomToFit": "تكبير لتناسب الشاشة"
}
}
}

View File

@@ -7366,6 +7366,19 @@
}
}
},
"SaveSVG": {
"description": "يحفظ ملفات SVG على القرص.",
"display_name": "حفظ SVG",
"inputs": {
"filename_prefix": {
"name": "بادئة اسم الملف",
"tooltip": "بادئة اسم الملف للحفظ. يمكن أن تتضمن معلومات تنسيق مثل %date:yyyy-MM-dd% أو %Empty Latent Image.width% لاستخدام قيم من العقد."
},
"svg": {
"name": "ملف SVG"
}
}
},
"SaveVideo": {
"description": "يحفظ الصور المدخلة في مجلد مخرجات ComfyUI الخاص بك.",
"display_name": "حفظ الفيديو",
@@ -8644,4 +8657,4 @@
}
}
}
}
}

View File

@@ -388,6 +388,10 @@
"Topbar (2nd-row)": "شريط الأعلى (الصف الثاني)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "عتبة التكبير للرسم بجودة منخفضة",
"tooltip": "عرض أشكال بجودة منخفضة عند التكبير للخارج"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "الحد الأقصى للإطارات في الثانية",
"tooltip": "الحد الأقصى لعدد الإطارات في الثانية التي يسمح للرسم أن يعرضها. يحد من استخدام GPU على حساب السلاسة. إذا كانت 0، يتم استخدام معدل تحديث الشاشة. الافتراضي: 0"
@@ -409,4 +413,4 @@
"pysssss_SnapToGrid": {
"name": "الالتصاق بالشبكة دائمًا"
}
}
}

View File

@@ -122,6 +122,9 @@
"Comfy_ExportWorkflowAPI": {
"label": "Export Workflow (API Format)"
},
"Comfy_Feedback": {
"label": "Give Feedback"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
@@ -257,9 +260,6 @@
"Comfy_User_SignOut": {
"label": "Sign Out"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Enable Vue Nodes"
},
"Workspace_CloseWorkflow": {
"label": "Close Current Workflow"
},

View File

@@ -18,7 +18,6 @@
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
"loadAllFolders": "Load All Folders",
"logoAlt": "ComfyUI Logo",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"terminal": "Terminal",
@@ -407,27 +406,6 @@
"migration": "Migration",
"desktopSettings": "Desktop Settings",
"chooseInstallationLocation": "Choose Installation Location",
"gpuPicker": {
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
"locationPicker": {
"title": "Choose where to install ComfyUI",
"subtitle": "Pick a folder for ComfyUI's files. We'll also set up Python there automatically.",
"pathPlaceholder": "/Users/username/Documents/ComfyUI",
"migrationPathPlaceholder": "Select existing ComfyUI installation (optional)",
"migrateFromExisting": "Migrate from existing installation",
"migrateDescription": "Copy or link your existing models, custom nodes, and configurations from a previous ComfyUI installation.",
"chooseDownloadServers": "Choose download servers manually",
"downloadServersDescription": "Select specific mirror servers for downloading Python, PyPI packages, and PyTorch based on your location."
},
"systemLocations": "System Locations",
"failedToSelectDirectory": "Failed to select directory",
"pathValidationFailed": "Failed to validate path",
@@ -512,26 +490,18 @@
"metricsDisabled": "Metrics Disabled",
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
},
"desktopStart": {
"initialising": "Initialising..."
},
"serverStart": {
"title": "Starting ComfyUI",
"troubleshoot": "Troubleshoot",
"reportIssue": "Report Issue",
"openLogs": "Open Logs",
"showTerminal": "Show Terminal",
"copySelectionTooltip": "Copy selection",
"copyAllTooltip": "Copy all",
"errorMessage": "Unable to start ComfyUI Desktop",
"installation": {
"title": "Installing ComfyUI"
},
"process": {
"initial-state": "Loading...",
"python-setup": "Setting up Python Environment...",
"starting-server": "Starting ComfyUI server...",
"ready": "Loading Human Interface",
"ready": "Finishing...",
"error": "Unable to start ComfyUI Desktop"
}
},
@@ -1103,7 +1073,7 @@
"queue": "Queue Panel"
},
"menuLabels": {
"File": "File",
"Workflow": "Workflow",
"Edit": "Edit",
"View": "View",
"Help": "Help",
@@ -1122,6 +1092,7 @@
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Zoom to fit": "Zoom to fit",
"Lock Canvas": "Lock Canvas",
"Move Selected Nodes Down": "Move Selected Nodes Down",
@@ -1130,9 +1101,8 @@
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Node Links": "Node Links",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
"Canvas Toggle Lock": "Canvas Toggle Lock",
"Minimap": "Minimap",
"Pin/Unpin Selected Items": "Pin/Unpin Selected Items",
"Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes",
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",
@@ -1148,6 +1118,7 @@
"Duplicate Current Workflow": "Duplicate Current Workflow",
"Export": "Export",
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
@@ -1166,11 +1137,10 @@
"Custom Nodes Manager": "Custom Nodes Manager",
"Custom Nodes (Legacy)": "Custom Nodes (Legacy)",
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
"Install Missing": "Install Missing",
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
"Check for Custom Node Updates": "Check for Custom Node Updates",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
@@ -1193,22 +1163,31 @@
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
"Sign Out": "Sign Out",
"Experimental: Enable Vue Nodes": "Experimental: Enable Vue Nodes",
"Close Current Workflow": "Close Current Workflow",
"Next Opened Workflow": "Next Opened Workflow",
"Previous Opened Workflow": "Previous Opened Workflow",
"Toggle Search Box": "Toggle Search Box",
"Bottom Panel": "Bottom Panel",
"Toggle Bottom Panel": "Toggle Bottom Panel",
"Show Keybindings Dialog": "Show Keybindings Dialog",
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Toggle Focus Mode": "Toggle Focus Mode",
"Focus Mode": "Focus Mode",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
"Workflows": "Workflows"
"Workflows": "Workflows",
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar",
"sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
"sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
"sideToolbar_queue": "sideToolbar.queue",
"sideToolbar_workflows": "sideToolbar.workflows"
},
"desktopMenu": {
"reinstall": "Reinstall",
@@ -1269,9 +1248,7 @@
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes",
"Assets": "Assets",
"Canvas Navigation": "Canvas Navigation"
"Vue Nodes": "Vue Nodes"
},
"serverConfigItems": {
"listen": {
@@ -1408,49 +1385,42 @@
"noise": "noise",
"sampling": "sampling",
"schedulers": "schedulers",
"audio": "audio",
"conditioning": "conditioning",
"loaders": "loaders",
"guiders": "guiders",
"api node": "api node",
"video": "video",
"ByteDance": "ByteDance",
"image": "image",
"preprocessors": "preprocessors",
"utils": "utils",
"string": "string",
"advanced": "advanced",
"guidance": "guidance",
"loaders": "loaders",
"model_merging": "model_merging",
"model_patches": "model_patches",
"chroma_radiance": "chroma_radiance",
"attention_experiments": "attention_experiments",
"conditioning": "conditioning",
"flux": "flux",
"hooks": "hooks",
"combine": "combine",
"cond single": "cond single",
"context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"video": "video",
"mask": "mask",
"deprecated": "deprecated",
"debug": "debug",
"model": "model",
"latent": "latent",
"audio": "audio",
"3d": "3d",
"ltxv": "ltxv",
"sd3": "sd3",
"sigmas": "sigmas",
"api node": "api node",
"BFL": "BFL",
"model_patches": "model_patches",
"unet": "unet",
"Gemini": "Gemini",
"text": "text",
"gligen": "gligen",
"video_models": "video_models",
"sd": "sd",
"Ideogram": "Ideogram",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"postprocessing": "postprocessing",
"transform": "transform",
"batch": "batch",
@@ -1460,44 +1430,34 @@
"Kling": "Kling",
"samplers": "samplers",
"operations": "operations",
"training": "training",
"lotus": "lotus",
"Luma": "Luma",
"MiniMax": "MiniMax",
"debug": "debug",
"model": "model",
"model_specific": "model_specific",
"Moonvalley Marey": "Moonvalley Marey",
"OpenAI": "OpenAI",
"cond pair": "cond pair",
"photomaker": "photomaker",
"Pika": "Pika",
"PixVerse": "PixVerse",
"utils": "utils",
"primitive": "primitive",
"qwen": "qwen",
"Recraft": "Recraft",
"edit_models": "edit_models",
"Rodin": "Rodin",
"Runway": "Runway",
"animation": "animation",
"api": "api",
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
"camera": "camera",
"Wan": "Wan"
"sd": "sd",
"Veo": "Veo"
},
"dataTypes": {
"*": "*",
"AUDIO": "AUDIO",
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
"BOOLEAN": "BOOLEAN",
"CAMERA_CONTROL": "CAMERA_CONTROL",
"CLIP": "CLIP",
@@ -1508,7 +1468,6 @@
"CONTROL_NET": "CONTROL_NET",
"FLOAT": "FLOAT",
"FLOATS": "FLOATS",
"GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES",
"GLIGEN": "GLIGEN",
"GUIDER": "GUIDER",
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
@@ -1520,25 +1479,17 @@
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"LOAD3D_CAMERA": "LOAD3D_CAMERA",
"LORA_MODEL": "LORA_MODEL",
"LOSS_MAP": "LOSS_MAP",
"LUMA_CONCEPTS": "LUMA_CONCEPTS",
"LUMA_REF": "LUMA_REF",
"MASK": "MASK",
"MESH": "MESH",
"MODEL": "MODEL",
"MODEL_PATCH": "MODEL_PATCH",
"MODEL_TASK_ID": "MODEL_TASK_ID",
"NOISE": "NOISE",
"OPENAI_CHAT_CONFIG": "OPENAI_CHAT_CONFIG",
"OPENAI_INPUT_FILES": "OPENAI_INPUT_FILES",
"PHOTOMAKER": "PHOTOMAKER",
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
"RECRAFT_COLOR": "RECRAFT_COLOR",
"RECRAFT_CONTROLS": "RECRAFT_CONTROLS",
"RECRAFT_V3_STYLE": "RECRAFT_V3_STYLE",
"RETARGET_TASK_ID": "RETARGET_TASK_ID",
"RIG_TASK_ID": "RIG_TASK_ID",
"SAMPLER": "SAMPLER",
"SIGMAS": "SIGMAS",
"STRING": "STRING",
@@ -1549,7 +1500,6 @@
"VAE": "VAE",
"VIDEO": "VIDEO",
"VOXEL": "VOXEL",
"WAN_CAMERA_EMBEDDING": "WAN_CAMERA_EMBEDDING",
"WEBCAM": "WEBCAM"
},
"maintenance": {
@@ -1912,33 +1862,5 @@
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"connectionError": "Please check your connection and try again",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular"
},
"desktopDialogs": {
"": {
"title": "Invalid Dialog",
"message": "Invalid dialog ID was provided.",
"buttons": {
"Close": "Close"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,34 +25,15 @@
"custom": "custom"
}
},
"Comfy_Assets_UseAssetAPI": {
"name": "Use Asset API for model library",
"tooltip": "Use new Asset API for model browsing"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_LeftMouseClickBehavior": {
"name": "Left Mouse Click Behavior",
"options": {
"Panning": "Panning",
"Select": "Select"
}
},
"Comfy_Canvas_MouseWheelScroll": {
"name": "Mouse Wheel Scroll",
"options": {
"Panning": "Panning",
"Zoom in/out": "Zoom in/out"
}
},
"Comfy_Canvas_NavigationMode": {
"name": "Navigation Mode",
"name": "Canvas Navigation Mode",
"options": {
"Standard (New)": "Standard (New)",
"Drag Navigation": "Drag Navigation",
"Custom": "Custom"
"Drag Navigation": "Drag Navigation"
}
},
"Comfy_Canvas_SelectionToolbox": {
@@ -362,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_Enabled": {
"name": "Enable Vue node rendering",
"tooltip": "Render nodes as Vue components instead of canvas elements. Experimental feature."
},
"Comfy_VueNodes_Widgets": {
"name": "Enable Vue widgets",
"tooltip": "Render widgets as Vue components within Vue nodes."
},
"Comfy_WidgetControlMode": {
"name": "Widget control mode",
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
@@ -399,9 +388,6 @@
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "Sort node IDs when saving workflow"
},
"Comfy_Workflow_WarnBlueprintOverwrite": {
"name": "Require confirmation to overwrite an existing subgraph blueprint"
},
"Comfy_Workflow_WorkflowTabsPosition": {
"name": "Opened workflows position",
"options": {
@@ -410,14 +396,14 @@
"Topbar (2nd-row)": "Topbar (2nd-row)"
}
},
"LiteGraph_Canvas_MaximumFps": {
"name": "Maximum FPS",
"tooltip": "The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0"
},
"LiteGraph_Canvas_MinFontSizeForLOD": {
"name": "Zoom Node Level of Detail - font size threshold",
"tooltip": "Controls when the nodes switch to low quality LOD rendering. Uses font size in pixels to determine when to switch. Set to 0 to disable. Values 1-24 set the minimum font size threshold for LOD - higher values (24px) = switch nodes to simplified rendering sooner when zooming out, lower values (1px) = maintain full node quality longer."
},
"LiteGraph_Canvas_MaximumFps": {
"name": "Maximum FPS",
"tooltip": "The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Scale node combo widget menus (lists) when zoomed in"
},

View File

@@ -122,6 +122,9 @@
"Comfy_ExportWorkflowAPI": {
"label": "Exportar flujo de trabajo (formato API)"
},
"Comfy_Feedback": {
"label": "Dar retroalimentación"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
@@ -306,4 +309,4 @@
"label": "Alternar Barra Lateral de Flujos de Trabajo",
"tooltip": "Flujos de Trabajo"
}
}
}

View File

@@ -324,9 +324,11 @@
"feedback": "Retroalimentación",
"filter": "Filtrar",
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
@@ -731,7 +733,9 @@
"Bottom Panel": "Panel inferior",
"Browse Templates": "Explorar plantillas",
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
"Canvas Toggle Minimap": "Lienzo: Alternar minimapa",
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
"Check for Updates": "Buscar actualizaciones",
"Clear Pending Tasks": "Borrar tareas pendientes",
@@ -756,6 +760,8 @@
"Export": "Exportar",
"Export (API)": "Exportar (API)",
"Fit Group To Contents": "Ajustar grupo a contenidos",
"Fit view to selected nodes": "Ajustar vista a los nodos seleccionados",
"Give Feedback": "Dar retroalimentación",
"Group Selected Nodes": "Agrupar nodos seleccionados",
"Help": "Ayuda",
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
@@ -802,11 +808,18 @@
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de la biblioteca de nodos",
"Toggle Queue Sidebar": "Alternar barra lateral de la cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
@@ -815,6 +828,7 @@
"Unlock Canvas": "Desbloquear lienzo",
"Unpack the selected Subgraph": "Desempaquetar el Subgrafo seleccionado",
"View": "Ver",
"Workflow": "Flujo de trabajo",
"Workflows": "Flujos de trabajo",
"Zoom In": "Acercar",
"Zoom Out": "Alejar",
@@ -825,7 +839,11 @@
"renderBypassState": "Mostrar estado de omisión",
"renderErrorState": "Mostrar estado de error",
"showGroups": "Mostrar marcos/grupos",
"showLinks": "Mostrar enlaces"
"showLinks": "Mostrar enlaces",
"sideToolbar_modelLibrary": "sideToolbar.bibliotecaDeModelos",
"sideToolbar_nodeLibrary": "sideToolbar.bibliotecaDeNodos",
"sideToolbar_queue": "sideToolbar.cola",
"sideToolbar_workflows": "sideToolbar.flujosDeTrabajo"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
@@ -902,6 +920,9 @@
"upscale_diffusion": "difusión_de_escalado",
"upscaling": "escalado",
"utils": "utilidades",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "video",
"video_models": "modelos_de_video"
},

View File

@@ -7366,6 +7366,19 @@
}
}
},
"SaveSVG": {
"description": "Guardar archivos SVG en el disco.",
"display_name": "Guardar SVG",
"inputs": {
"filename_prefix": {
"name": "prefijo_de_archivo",
"tooltip": "El prefijo para el archivo a guardar. Esto puede incluir información de formato como %date:yyyy-MM-dd% o %Empty Latent Image.width% para incluir valores de los nodos."
},
"svg": {
"name": "svg"
}
}
},
"SaveVideo": {
"description": "Guarda las imágenes de entrada en tu directorio de salida de ComfyUI.",
"display_name": "Guardar video",
@@ -8644,4 +8657,4 @@
}
}
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validar flujos de trabajo"
},
"Comfy_VueNodes_Enabled": {
"name": "Habilitar renderizado de nodos Vue",
"tooltip": "Renderiza los nodos como componentes Vue en lugar de elementos canvas. Función experimental."
},
"Comfy_VueNodes_Widgets": {
"name": "Habilitar widgets de Vue",
"tooltip": "Renderiza los widgets como componentes de Vue dentro de los nodos de Vue."
},
"Comfy_WidgetControlMode": {
"name": "Modo de control del widget",
"options": {
@@ -388,6 +396,10 @@
"Topbar (2nd-row)": "Barra superior (2ª fila)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "Umbral de renderizado de baja calidad al hacer zoom",
"tooltip": "Renderiza formas de baja calidad cuando se aleja"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "FPS máximo",
"tooltip": "La cantidad máxima de cuadros por segundo que se permite renderizar en el lienzo. Limita el uso de la GPU a costa de la suavidad. Si es 0, se utiliza la tasa de refresco de la pantalla. Predeterminado: 0"
@@ -409,4 +421,4 @@
"pysssss_SnapToGrid": {
"name": "Siempre ajustar a la cuadrícula"
}
}
}

View File

@@ -122,6 +122,9 @@
"Comfy_ExportWorkflowAPI": {
"label": "Exporter le flux de travail (format API)"
},
"Comfy_Feedback": {
"label": "Retour d'information"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
@@ -306,4 +309,4 @@
"label": "Basculer la barre latérale des flux de travail",
"tooltip": "Flux de travail"
}
}
}

View File

@@ -324,9 +324,11 @@
"feedback": "Commentaires",
"filter": "Filtrer",
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
@@ -731,7 +733,9 @@
"Bottom Panel": "Panneau inférieur",
"Browse Templates": "Parcourir les modèles",
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
"Canvas Toggle Minimap": "Basculer la mini-carte du canevas",
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
"Check for Updates": "Vérifier les mises à jour",
"Clear Pending Tasks": "Effacer les tâches en attente",
@@ -756,6 +760,8 @@
"Export": "Exporter",
"Export (API)": "Exporter (API)",
"Fit Group To Contents": "Ajuster le groupe au contenu",
"Fit view to selected nodes": "Ajuster la vue aux nœuds sélectionnés",
"Give Feedback": "Donnez votre avis",
"Group Selected Nodes": "Grouper les nœuds sélectionnés",
"Help": "Aide",
"Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor",
@@ -802,11 +808,18 @@
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Essential Bottom Panel": "Basculer le panneau inférieur essentiel",
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
"Toggle Queue Sidebar": "Afficher/Masquer la barre latérale de la file dattente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle View Controls Bottom Panel": "Basculer le panneau inférieur des contrôles daffichage",
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
@@ -815,6 +828,7 @@
"Unlock Canvas": "Déverrouiller le canevas",
"Unpack the selected Subgraph": "Décompresser le Subgraph sélectionné",
"View": "Afficher",
"Workflow": "Flux de travail",
"Workflows": "Flux de travail",
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière",
@@ -825,7 +839,12 @@
"renderBypassState": "Afficher l'état de contournement",
"renderErrorState": "Afficher l'état d'erreur",
"showGroups": "Afficher les cadres/groupes",
"showLinks": "Afficher les liens"
"showLinks": "Afficher les liens",
"Zoom Out": "Zoom arrière",
"sideToolbar_modelLibrary": "Bibliothèque de modèles",
"sideToolbar_nodeLibrary": "Bibliothèque de nœuds",
"sideToolbar_queue": "File d'attente",
"sideToolbar_workflows": "Flux de travail"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
@@ -902,6 +921,9 @@
"upscale_diffusion": "diffusion_de_mise_à_l'échelle",
"upscaling": "mise_à_l'échelle",
"utils": "utilitaires",
"v1": "v1",
"v2": "v2",
"v3": "v3",
"video": "vidéo",
"video_models": "modèles_vidéo"
},

View File

@@ -7366,6 +7366,19 @@
}
}
},
"SaveSVG": {
"description": "Enregistrer les fichiers SVG sur le disque.",
"display_name": "Enregistrer SVG",
"inputs": {
"filename_prefix": {
"name": "préfixe_nom_fichier",
"tooltip": "Le préfixe pour le fichier à enregistrer. Cela peut inclure des informations de formatage telles que %date:yyyy-MM-dd% ou %Empty Latent Image.width% pour inclure des valeurs provenant des nœuds."
},
"svg": {
"name": "svg"
}
}
},
"SaveVideo": {
"description": "Enregistre les images d'entrée dans votre répertoire de sortie ComfyUI.",
"display_name": "Enregistrer la vidéo",
@@ -8644,4 +8657,4 @@
}
}
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Valider les flux de travail"
},
"Comfy_VueNodes_Enabled": {
"name": "Activer le rendu des nœuds Vue",
"tooltip": "Rendre les nœuds comme composants Vue au lieu déléments canvas. Fonctionnalité expérimentale."
},
"Comfy_VueNodes_Widgets": {
"name": "Activer les widgets Vue",
"tooltip": "Rendre les widgets comme composants Vue à l'intérieur des nœuds Vue."
},
"Comfy_WidgetControlMode": {
"name": "Mode de contrôle du widget",
"options": {
@@ -388,6 +396,10 @@
"Topbar (2nd-row)": "Barre supérieure (2ème rangée)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "Seuil de zoom pour le rendu de faible qualité",
"tooltip": "Rendre des formes de faible qualité lorsqu'on est dézoomé"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "FPS maximum",
"tooltip": "Le nombre maximum d'images par seconde que le canevas est autorisé à rendre. Limite l'utilisation du GPU au détriment de la fluidité. Si 0, le taux de rafraîchissement de l'écran est utilisé. Par défaut : 0"
@@ -409,4 +421,4 @@
"pysssss_SnapToGrid": {
"name": "Toujours aligner sur la grille"
}
}
}

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