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
90 changed files with 5734 additions and 2436 deletions

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: {

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

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,49 +24,35 @@ export default defineConfig([
]
},
{
files: ['./**/*.{ts,mts}'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: tseslint.parser,
parserOptions: {
parser: tseslint.parser,
projectService: true,
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: {
@@ -82,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',
@@ -151,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.5",
"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"
},

1048
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,18 +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-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;
@@ -103,12 +99,12 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--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 */
@@ -121,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);
}
@@ -136,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

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

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

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

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

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

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

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

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

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

@@ -30,7 +30,7 @@
"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_NavigationMode": {
"name": "Navigation Mode",
"name": "Canvas Navigation Mode",
"options": {
"Standard (New)": "Standard (New)",
"Drag Navigation": "Drag Navigation"

View File

@@ -54,7 +54,7 @@
<div v-for="item in items" :key="item.name" class="mb-4">
<FormItem
:id="item.id"
v-model:form-value="item.value"
v-model:formValue="item.value"
:item="translateItem(item)"
:label-class="{
'text-highlight': item.initialValue !== item.value

View File

@@ -2,7 +2,7 @@
<div class="settings-container">
<ScrollPanel class="settings-sidebar shrink-0 p-2 w-48 2xl:w-64">
<SearchBox
v-model:model-value="searchQuery"
v-model:modelValue="searchQuery"
class="settings-search-box w-full mb-2"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"

View File

@@ -131,26 +131,11 @@ export const useLitegraphSettings = () => {
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
| 'standard'
| 'legacy'
| 'custom'
LiteGraph.canvasNavigationMode = navigationMode
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
})
watchEffect(() => {
const leftMouseBehavior = settingStore.get(
'Comfy.Canvas.LeftMouseClickBehavior'
) as 'panning' | 'select'
LiteGraph.leftMouseClickBehavior = leftMouseBehavior
})
watchEffect(() => {
const mouseWheelScroll = settingStore.get(
'Comfy.Canvas.MouseWheelScroll'
) as 'panning' | 'zoom'
LiteGraph.mouseWheelScroll = mouseWheelScroll
})
watchEffect(() => {
LiteGraph.saveViewportWithGraph = settingStore.get(
'Comfy.EnableWorkflowViewRestore'

View File

@@ -1,5 +1,4 @@
import { LinkMarkerShape, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
import type { Keybinding } from '@/schemas/keyBindingSchema'
@@ -139,95 +138,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas Navigation', 'NavigationMode'],
name: 'Navigation Mode',
defaultValue: 'legacy',
type: 'combo',
sortOrder: 100,
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Drag Navigation' },
{ value: 'custom', text: 'Custom' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'legacy'
},
onChange: async (newValue: string) => {
const settingStore = useSettingStore()
if (newValue === 'standard') {
// Update related settings to match standard mode - select + panning
await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'select')
await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'panning')
} else if (newValue === 'legacy') {
// Update related settings to match legacy mode - panning + zoom
await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'panning')
await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'zoom')
}
}
},
{
id: 'Comfy.Canvas.LeftMouseClickBehavior',
category: ['LiteGraph', 'Canvas Navigation', 'LeftMouseClickBehavior'],
name: 'Left Mouse Click Behavior',
defaultValue: 'panning',
type: 'radio',
sortOrder: 50,
options: [
{ value: 'panning', text: 'Panning' },
{ value: 'select', text: 'Select' }
],
versionAdded: '1.27.4',
onChange: async (newValue: string) => {
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
if (navigationMode !== 'custom') {
if (
(newValue === 'select' && navigationMode === 'standard') ||
(newValue === 'panning' && navigationMode === 'legacy')
) {
return
}
// only set to custom if it doesn't match the preset modes
await settingStore.set('Comfy.Canvas.NavigationMode', 'custom')
}
}
},
{
id: 'Comfy.Canvas.MouseWheelScroll',
category: ['LiteGraph', 'Canvas Navigation', 'MouseWheelScroll'],
name: 'Mouse Wheel Scroll',
defaultValue: 'zoom',
type: 'radio',
options: [
{ value: 'panning', text: 'Panning' },
{ value: 'zoom', text: 'Zoom in/out' }
],
versionAdded: '1.27.4',
onChange: async (newValue: string) => {
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
if (navigationMode !== 'custom') {
if (
(newValue === 'panning' && navigationMode === 'standard') ||
(newValue === 'zoom' && navigationMode === 'legacy')
) {
return
}
// only set to custom if it doesn't match the preset modes
await settingStore.set('Comfy.Canvas.NavigationMode', 'custom')
}
}
},
{
id: 'Comfy.Graph.CanvasInfo',
category: ['LiteGraph', 'Canvas', 'CanvasInfo'],
@@ -903,6 +813,21 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 8,
versionAdded: '1.26.7'
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas', 'CanvasNavigationMode'],
name: 'Canvas Navigation Mode',
defaultValue: 'legacy',
type: 'combo',
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Drag Navigation' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'legacy'
}
},
{
id: 'Comfy.Canvas.SelectionToolbox',
category: ['LiteGraph', 'Canvas', 'SelectionToolbox'],

View File

@@ -2,7 +2,7 @@ import { toRaw } from 'vue'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
@@ -346,7 +346,7 @@ export const useWorkflowService = () => {
*/
const insertWorkflow = async (
workflow: ComfyWorkflow,
options: { position?: Point } = {}
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const workflowJSON = toRaw(loadedWorkflow.initialState)

View File

@@ -1,49 +0,0 @@
import type { InjectionKey } from 'vue'
import type { Point } from '@/renderer/core/layout/types'
/**
* Lightweight, injectable transform state used by layout-aware components.
*
* Consumers use this interface to convert coordinates between LiteGraph's
* canvas space and the DOM's screen space, access the current pan/zoom
* (camera), and perform basic viewport culling checks.
*
* Coordinate mapping:
* - screen = (canvas + offset) * scale
* - canvas = screen / scale - offset
*
* The full implementation and additional helpers live in
* `useTransformState()`. This interface deliberately exposes only the
* minimal surface needed outside that composable.
*
* @example
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
interface TransformState {
/** Convert a screen-space point (CSS pixels) to canvas space. */
screenToCanvas: (p: Point) => Point
/** Convert a canvas-space point to screen space (CSS pixels). */
canvasToScreen: (p: Point) => Point
/** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */
camera?: { x: number; y: number; z: number }
/**
* Test whether a node's rectangle intersects the (expanded) viewport.
* Handy for viewport culling and lazy work.
*
* @param nodePos Top-left in canvas space `[x, y]`
* @param nodeSize Size in canvas units `[width, height]`
* @param viewport Screen-space viewport `{ width, height }`
* @param margin Optional fractional margin (e.g. `0.2` = 20%)
*/
isNodeInViewport?: (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin?: number
) => boolean
}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -0,0 +1,229 @@
/**
* DOM-based slot registration with performance optimization
*
* Measures the actual DOM position of a Vue slot connector and registers it
* into the LayoutStore so hit-testing and link rendering use the true position.
*
* Performance strategy:
* - Cache slot offset relative to node (avoids DOM reads during drag)
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
* - Batch DOM reads via requestAnimationFrame
* - Only remeasure on structural changes (resize, collapse, LOD)
*/
import {
type Ref,
type WatchStopHandle,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
export type TransformState = {
screenToCanvas: (p: LayoutPoint) => LayoutPoint
}
// Shared RAF queue for batching measurements
const measureQueue = new Set<() => void>()
let rafId: number | null = null
// Track mounted components to prevent execution on unmounted ones
const mountedComponents = new WeakSet<object>()
function scheduleMeasurement(fn: () => void) {
measureQueue.add(fn)
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null
const batch = Array.from(measureQueue)
measureQueue.clear()
batch.forEach((measure) => measure())
})
}
}
const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
}
>()
interface SlotRegistrationOptions {
nodeId: string
slotIndex: number
isInput: boolean
element: Ref<HTMLElement | null>
transform?: TransformState
}
export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const { nodeId, slotIndex, isInput, element: elRef, transform } = options
// Early return if no nodeId
if (!nodeId || nodeId === '') {
return {
remeasure: () => {}
}
}
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Track if this component is mounted
const componentToken = {}
// Cached offset from node position (avoids DOM reads during drag)
const cachedOffset = ref<LayoutPoint | null>(null)
const lastMeasuredBounds = ref<DOMRect | null>(null)
// Measure DOM and cache offset (expensive, minimize calls)
const measureAndCacheOffset = () => {
// Skip if component was unmounted
if (!mountedComponents.has(componentToken)) return
const el = elRef.value
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
const prev = lastMeasuredBounds.value
if (
Math.abs(rect.left - prev.left) < 0.5 &&
Math.abs(rect.top - prev.top) < 0.5 &&
Math.abs(rect.width - prev.width) < 0.5 &&
Math.abs(rect.height - prev.height) < 0.5
) {
return // No significant change - skip update
}
}
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
const centerCanvas = transform.screenToCanvas(centerScreen)
// Cache offset from node position for fast updates during drag
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
cachedOffset.value = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
}
updateSlotPosition(centerCanvas)
}
// Fast update using cached offset (no DOM read)
const updateFromCachedOffset = () => {
if (!cachedOffset.value) {
// No cached offset yet, need to measure
scheduleMeasurement(measureAndCacheOffset)
return
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) {
return
}
// Calculate absolute position from node position + cached offset
const centerCanvas = {
x: nodeLayout.position.x + cachedOffset.value.x,
y: nodeLayout.position.y + cachedOffset.value.y
}
updateSlotPosition(centerCanvas)
}
// Update slot position in layout store
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
onMounted(async () => {
// Mark component as mounted
mountedComponents.add(componentToken)
// Initial measure after mount
await nextTick()
measureAndCacheOffset()
// Subscribe to node position changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stopWatcher = watch(
nodeRef,
(newLayout) => {
if (newLayout) {
// Node moved/resized - update using cached offset
updateFromCachedOffset()
}
},
{ immediate: false }
)
// Store cleanup functions without type assertions
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
cleanupFunctions.set(elRef, cleanup)
})
onUnmounted(() => {
// Mark component as unmounted
mountedComponents.delete(componentToken)
// Clean up watchers and listeners
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
cleanupFunctions.delete(elRef)
}
// Remove from layout store
layoutStore.deleteSlotLayout(slotKey)
// Remove from measurement queue if pending
measureQueue.delete(measureAndCacheOffset)
})
return {
// Expose for forced remeasure on structural changes
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
}
}

View File

@@ -38,10 +38,6 @@ import {
type RerouteLayout,
type SlotLayout
} from '@/renderer/core/layout/types'
import {
isBoundsEqual,
isPointEqual
} from '@/renderer/core/layout/utils/geometry'
import {
REROUTE_RADIUS,
boundsIntersect,
@@ -396,8 +392,12 @@ class LayoutStoreImpl implements LayoutStore {
// Short-circuit if bounds and centerPos unchanged
if (
existing &&
isBoundsEqual(existing.bounds, layout.bounds) &&
isPointEqual(existing.centerPos, layout.centerPos)
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
) {
// Only update path if provided (for hit detection)
if (layout.path) {
@@ -436,13 +436,6 @@ class LayoutStoreImpl implements LayoutStore {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
return
}
// Update spatial index
this.slotSpatialIndex.update(key, layout.bounds)
} else {
@@ -453,34 +446,6 @@ class LayoutStoreImpl implements LayoutStore {
this.slotLayouts.set(key, layout)
}
/**
* Batch update slot layouts and spatial index in one pass
*/
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
): void {
if (!updates.length) return
// Update spatial index and map entries (skip unchanged)
for (const { key, layout } of updates) {
const existing = this.slotLayouts.get(key)
if (existing) {
// Short-circuit if geometry is unchanged
if (
isPointEqual(existing.position, layout.position) &&
isBoundsEqual(existing.bounds, layout.bounds)
) {
continue
}
this.slotSpatialIndex.update(key, layout.bounds)
} else {
this.slotSpatialIndex.insert(key, layout.bounds)
}
this.slotLayouts.set(key, layout)
}
}
/**
* Delete slot layout data
*/
@@ -589,8 +554,12 @@ class LayoutStoreImpl implements LayoutStore {
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
if (
existing &&
isBoundsEqual(existing.bounds, layout.bounds) &&
isPointEqual(existing.centerPos, layout.centerPos)
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
) {
// Only update path if provided (for hit detection)
if (layout.path) {
@@ -999,6 +968,9 @@ class LayoutStoreImpl implements LayoutStore {
// Hit detection queries can run before CRDT updates complete
this.spatialIndex.update(operation.nodeId, newBounds)
// Update associated slot positions synchronously
this.updateNodeSlotPositions(operation.nodeId, operation.position)
// Then update CRDT
ynode.set('position', operation.position)
this.updateNodeBounds(ynode, operation.position, size)
@@ -1025,6 +997,9 @@ class LayoutStoreImpl implements LayoutStore {
// Hit detection queries can run before CRDT updates complete
this.spatialIndex.update(operation.nodeId, newBounds)
// Update associated slot positions synchronously (size changes may affect slot positions)
this.updateNodeSlotPositions(operation.nodeId, position)
// Then update CRDT
ynode.set('size', operation.size)
this.updateNodeBounds(ynode, position, operation.size)
@@ -1305,6 +1280,29 @@ class LayoutStoreImpl implements LayoutStore {
}
}
/**
* Update slot positions when a node moves
* TODO: This should be handled by the layout sync system (useSlotLayoutSync)
* rather than manually here. For now, we'll mark affected slots as needing recalculation.
*/
private updateNodeSlotPositions(nodeId: NodeId, _nodePosition: Point): void {
// Mark all slots for this node as potentially stale
// The layout sync system will recalculate positions on the next frame
const slotsToRemove: string[] = []
for (const [key, slotLayout] of this.slotLayouts) {
if (slotLayout.nodeId === nodeId) {
slotsToRemove.push(key)
}
}
// Remove from spatial index so they'll be recalculated
for (const key of slotsToRemove) {
this.slotSpatialIndex.remove(key)
this.slotLayouts.delete(key)
}
}
// Helper methods
private notifyChange(change: LayoutChange): void {

View File

@@ -14,7 +14,6 @@
import { computed, provide } from 'vue'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
@@ -40,7 +39,7 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
trackPan: true
})
provide(TransformStateKey, {
provide('transformState', {
camera,
canvasToScreen,
screenToCanvas,

View File

@@ -330,8 +330,4 @@ export interface LayoutStore {
batchUpdateNodeBounds(
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
): void
batchUpdateSlotLayouts(
updates: Array<{ key: string; layout: SlotLayout }>
): void
}

View File

@@ -1,15 +0,0 @@
import type { Bounds, Point, Size } from '@/renderer/core/layout/types'
export function isPointEqual(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y
}
export function isBoundsEqual(a: Bounds, b: Bounds): boolean {
return (
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
)
}
export function isSizeEqual(a: Size, b: Size): boolean {
return a.width === b.width && a.height === b.height
}

View File

@@ -4,18 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useDomSlotRegistration } from '@/renderer/core/layout/slots/useDomSlotRegistration'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
// Mock composable used by InputSlot/OutputSlot so we can assert call params
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() }))
})
)
vi.mock('@/renderer/core/layout/slots/useDomSlotRegistration', () => ({
useDomSlotRegistration: vi.fn(() => ({ remeasure: vi.fn() }))
}))
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
@@ -52,7 +49,7 @@ const mountOutputSlot = (props: OutputSlotProps) =>
describe('InputSlot/OutputSlot', () => {
beforeEach(() => {
vi.mocked(useSlotElementTracking).mockClear()
vi.mocked(useDomSlotRegistration).mockClear()
})
it('InputSlot registers with correct options', () => {
@@ -62,11 +59,11 @@ describe('InputSlot/OutputSlot', () => {
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-1',
index: 3,
type: 'input'
slotIndex: 3,
isInput: true
})
)
})
@@ -78,11 +75,11 @@ describe('InputSlot/OutputSlot', () => {
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect(useDomSlotRegistration).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-2',
index: 1,
type: 'output'
slotIndex: 1,
isInput: false
})
)
})

View File

@@ -32,6 +32,7 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -40,7 +41,11 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -70,6 +75,11 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -82,10 +92,11 @@ watchEffect(() => {
slotElRef.value = el || null
})
useSlotElementTracking({
useDomSlotRegistration({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input',
element: slotElRef
slotIndex: props.index,
isInput: true,
element: slotElRef,
transform: transformState
})
</script>

View File

@@ -7,9 +7,9 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-charcoal-800',
'bg-white dark-theme:bg-charcoal-100',
'lg-node absolute rounded-2xl',
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
'outline-transparent -outline-offset-2 outline-2',
borderClass,
@@ -114,18 +114,6 @@
:lod-level="lodLevel"
:image-urls="nodeImageUrls"
/>
<!-- Live preview image -->
<div
v-if="shouldShowPreviewImg"
v-memo="[latestPreviewUrl]"
class="px-4"
>
<img
:src="latestPreviewUrl"
alt="preview"
class="w-full max-h-64 object-contain"
/>
</div>
</div>
</template>
</div>
@@ -147,11 +135,9 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -213,7 +199,19 @@ if (!selectedNodeIds) {
}
// Inject transform state for coordinate conversion
const transformState = inject(TransformStateKey)
const transformState = inject('transformState') as
| {
camera: { z: number }
canvasToScreen: (point: { x: number; y: number }) => {
x: number
y: number
}
screenToCanvas: (point: { x: number; y: number }) => {
x: number
y: number
}
}
| undefined
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
@@ -270,7 +268,7 @@ const {
} = useNodeLayout(nodeData.id)
onMounted(() => {
if (size && transformState?.camera) {
if (size && transformState) {
const scale = transformState.camera.z
const screenSize = {
width: size.width * scale,
@@ -311,17 +309,9 @@ const hasCustomContent = computed(() => {
// Computed classes and conditions for better reusability
const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
'bg-sand-100 dark-theme:bg-charcoal-300 h-[1px] mx-0 w-full'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
nodeData.id,
{
isMinimalLOD,
isCollapsed
}
)
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && nodeData.widgets?.length

View File

@@ -33,6 +33,7 @@
import {
type ComponentPublicInstance,
computed,
inject,
onErrorCaptured,
ref,
watchEffect
@@ -41,7 +42,11 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -72,6 +77,11 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -84,10 +94,11 @@ watchEffect(() => {
slotElRef.value = el || null
})
useSlotElementTracking({
useDomSlotRegistration({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output',
element: slotElRef
slotIndex: props.index,
isInput: false,
element: slotElRef,
transform: transformState
})
</script>

View File

@@ -1,220 +0,0 @@
/**
* Centralized Slot Element Tracking
*
* Registers slot connector DOM elements per node, measures their canvas-space
* positions in a single batched pass, and caches offsets so that node moves
* update slot positions without DOM reads.
*/
import { type Ref, onMounted, onUnmounted, watch } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import {
isPointEqual,
isSizeEqual
} from '@/renderer/core/layout/utils/geometry'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
// RAF batching
const pendingNodes = new Set<string>()
let rafId: number | null = null
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
if (rafId == null) {
rafId = requestAnimationFrame(() => {
rafId = null
flushScheduledSlotLayoutSync()
})
}
}
function flushScheduledSlotLayoutSync() {
if (pendingNodes.size === 0) return
const conv = useSharedCanvasPositionConversion()
for (const nodeId of Array.from(pendingNodes)) {
pendingNodes.delete(nodeId)
syncNodeSlotLayoutsFromDOM(nodeId, conv)
}
}
export function syncNodeSlotLayoutsFromDOM(
nodeId: string,
conv?: ReturnType<typeof useSharedCanvasPositionConversion>
) {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
const rect = entry.el.getBoundingClientRect()
const screenCenter: [number, number] = [
rect.left + rect.width / 2,
rect.top + rect.height / 2
]
const [x, y] = (
conv ?? useSharedCanvasPositionConversion()
).clientPosToCanvasPos(screenCenter)
const centerCanvas = { x, y }
// Cache offset relative to node position for fast updates later
entry.cachedOffset = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
// Persist layout in canvas coordinates
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
key: slotKey,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
}
function updateNodeSlotsFromCache(nodeId: string) {
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) return
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
if (!entry.cachedOffset) {
// schedule a sync to seed offset
scheduleSlotLayoutSync(nodeId)
continue
}
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
batch.push({
key: slotKey,
layout: {
nodeId,
index: entry.index,
type: entry.type,
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
}
})
}
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
}
export function useSlotElementTracking(options: {
nodeId: string
index: number
type: 'input' | 'output'
element: Ref<HTMLElement | null>
}) {
const { nodeId, index, type, element } = options
const nodeSlotRegistryStore = useNodeSlotRegistryStore()
onMounted(() => {
if (!nodeId) return
const stop = watch(
element,
(el) => {
if (!el) return
// Ensure node entry
const node = nodeSlotRegistryStore.ensureNode(nodeId)
if (!node.stopWatch) {
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
const stopPositionWatch = watch(
() => layoutRef.value?.position,
(newPosition, oldPosition) => {
if (!newPosition) return
if (!oldPosition || !isPointEqual(newPosition, oldPosition)) {
updateNodeSlotsFromCache(nodeId)
}
}
)
const stopSizeWatch = watch(
() => layoutRef.value?.size,
(newSize, oldSize) => {
if (!newSize) return
if (!oldSize || !isSizeEqual(newSize, oldSize)) {
scheduleSlotLayoutSync(nodeId)
}
}
)
node.stopWatch = () => {
stopPositionWatch()
stopSizeWatch()
}
}
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
node.slots.set(slotKey, { el, index, type })
// Seed initial sync from DOM
scheduleSlotLayoutSync(nodeId)
// Stop watching once registered
stop()
},
{ immediate: true, flush: 'post' }
)
})
onUnmounted(() => {
if (!nodeId) return
const node = nodeSlotRegistryStore.getNode(nodeId)
if (!node) return
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, type === 'input')
node.slots.delete(slotKey)
layoutStore.deleteSlotLayout(slotKey)
// If node has no more slots, clean up
if (node.slots.size === 0) {
// Stop the node-level watcher when the last slot is gone
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}
})
return {
requestSlotLayoutSync: () => scheduleSlotLayoutSync(nodeId)
}
}

View File

@@ -10,13 +10,9 @@
*/
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
*/
@@ -58,12 +54,8 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
// Canvas is ready when this code runs; no defensive guards needed.
const conv = useSharedCanvasPositionConversion()
// Group updates by type, then flush via each config's handler
// Group updates by element type
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
// Track nodes whose slots should be resynced after node size changes
const nodesNeedingSlotResync = new Set<string>()
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) continue
@@ -84,50 +76,30 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!elementType || !elementId) continue
// Use contentBoxSize when available; fall back to contentRect for older engines/tests
const contentBox = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = contentBox.inlineSize
const height = contentBox.blockSize
// Screen-space rect
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
const bounds: Bounds = {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
x: rect.left,
y: rect.top,
width,
height: height
}
let updates = updatesByType.get(elementType)
if (!updates) {
updates = []
updatesByType.set(elementType, updates)
if (!updatesByType.has(elementType)) {
updatesByType.set(elementType, [])
}
updates.push({ id: elementId, bounds })
// If this entry is a node, mark it for slot layout resync
if (elementType === 'node' && elementId) {
nodesNeedingSlotResync.add(elementId)
const updates = updatesByType.get(elementType)
if (updates) {
updates.push({ id: elementId, bounds })
}
}
// Flush per-type
// Process updates by type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config && updates.length) config.updateHandler(updates)
}
// After node bounds are updated, refresh slot cached offsets and layouts
if (nodesNeedingSlotResync.size > 0) {
for (const nodeId of nodesNeedingSlotResync) {
syncNodeSlotLayoutsFromDOM(nodeId)
if (config && updates.length > 0) {
config.updateHandler(updates)
}
}
})
@@ -162,11 +134,11 @@ export function useVueElementTracking(
if (!(element instanceof HTMLElement) || !appIdentifier) return
const config = trackingConfigs.get(trackingType)
if (!config) return
// Set the data attribute expected by the RO pipeline for this type
element.dataset[config.dataAttribute] = appIdentifier
resizeObserver.observe(element)
if (config) {
// Set the appropriate data attribute
element.dataset[config.dataAttribute] = appIdentifier
resizeObserver.observe(element)
}
})
onUnmounted(() => {
@@ -174,10 +146,10 @@ export function useVueElementTracking(
if (!(element instanceof HTMLElement)) return
const config = trackingConfigs.get(trackingType)
if (!config) return
// Remove the data attribute and observer
delete element.dataset[config.dataAttribute]
resizeObserver.unobserve(element)
if (config) {
// Remove the data attribute
delete element.dataset[config.dataAttribute]
resizeObserver.unobserve(element)
}
})
}

View File

@@ -7,7 +7,6 @@
import { computed, inject } from 'vue'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource, type Point } from '@/renderer/core/layout/types'
@@ -21,7 +20,12 @@ export function useNodeLayout(nodeId: string) {
const mutations = useLayoutMutations()
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)

View File

@@ -1,51 +0,0 @@
import { storeToRefs } from 'pinia'
import { type Ref, computed } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
export const useNodePreviewState = (
nodeId: string,
options?: {
isMinimalLOD?: Ref<boolean>
isCollapsed?: Ref<boolean>
}
) => {
const workflowStore = useWorkflowStore()
const { nodePreviewImages } = storeToRefs(useNodeOutputStore())
const locatorId = computed(() => workflowStore.nodeIdToNodeLocatorId(nodeId))
const previewUrls = computed(() => {
const key = locatorId.value
if (!key) return undefined
const urls = nodePreviewImages.value[key]
return urls?.length ? urls : undefined
})
const hasPreview = computed(() => !!previewUrls.value?.length)
const latestPreviewUrl = computed(() => {
const urls = previewUrls.value
return urls?.length ? urls.at(-1) : ''
})
const shouldShowPreviewImg = computed(() => {
if (!options?.isMinimalLOD || !options?.isCollapsed) {
return hasPreview.value
}
return (
!options.isMinimalLOD.value &&
!options.isCollapsed.value &&
hasPreview.value
)
})
return {
locatorId,
previewUrls,
hasPreview,
latestPreviewUrl,
shouldShowPreviewImg
}
}

View File

@@ -1,50 +0,0 @@
import { defineStore } from 'pinia'
import { markRaw } from 'vue'
type SlotEntry = {
el: HTMLElement
index: number
type: 'input' | 'output'
cachedOffset?: { x: number; y: number }
}
type NodeEntry = {
nodeId: string
slots: Map<string, SlotEntry>
stopWatch?: () => void
}
export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => {
const registry = markRaw(new Map<string, NodeEntry>())
function getNode(nodeId: string) {
return registry.get(nodeId)
}
function ensureNode(nodeId: string) {
let node = registry.get(nodeId)
if (!node) {
node = {
nodeId,
slots: markRaw(new Map<string, SlotEntry>())
}
registry.set(nodeId, node)
}
return node
}
function deleteNode(nodeId: string) {
registry.delete(nodeId)
}
function clear() {
registry.clear()
}
return {
getNode,
ensureNode,
deleteNode,
clear
}
})

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-1">
<Galleria
v-model:active-index="activeIndex"
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:show-thumbnails="showThumbnails"

View File

@@ -1,432 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetMarkdown from './WidgetMarkdown.vue'
// Mock the markdown renderer utility
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((markdown: string) => {
// Simple mock that converts some markdown to HTML
return markdown
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
.replace(/\n/g, '<br>')
})
}))
describe('WidgetMarkdown Dual Mode Display', () => {
const createMockWidget = (
value: string = '# Default Heading\nSome **bold** text.',
options: Record<string, unknown> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_markdown',
type: 'string',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
) => {
return mount(WidgetMarkdown, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,
readonly
}
})
}
const clickToEdit = async (wrapper: ReturnType<typeof mount>) => {
const container = wrapper.find('.widget-markdown')
await container.trigger('click')
await nextTick()
return container
}
const blurTextarea = async (wrapper: ReturnType<typeof mount>) => {
const textarea = wrapper.find('textarea')
if (textarea.exists()) {
await textarea.trigger('blur')
await nextTick()
}
return textarea
}
describe('Display Mode', () => {
it('renders markdown content as HTML in display mode', () => {
const markdown = '# Heading\nSome **bold** and *italic* text.'
const widget = createMockWidget(markdown)
const wrapper = mountComponent(widget, markdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
expect(displayDiv.html()).toContain('<h1>Heading</h1>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('starts in display mode by default', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('applies styling classes to display container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('text-xs')
expect(displayDiv.classes()).toContain('min-h-[60px]')
expect(displayDiv.classes()).toContain('rounded-lg')
expect(displayDiv.classes()).toContain('px-4')
expect(displayDiv.classes()).toContain('py-2')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
it('handles empty markdown content', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget, '')
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
expect(displayDiv.text()).toBe('')
})
})
describe('Edit Mode Toggle', () => {
it('switches to edit mode when clicked', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(false)
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('does not switch to edit mode when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
await clickToEdit(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('does not switch to edit mode when already editing', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
// First click to enter edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
// Second click should not have any effect
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
})
it('switches back to display mode on textarea blur', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
await blurTextarea(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
expect(wrapper.find('textarea').exists()).toBe(false)
})
})
describe('Edit Mode', () => {
it('displays textarea with current value when editing', async () => {
const markdown = '# Original Content'
const widget = createMockWidget(markdown)
const wrapper = mountComponent(widget, markdown)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
expect(textarea.element.value).toBe('# Original Content')
})
it('applies styling and configuration to textarea', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.findComponent({ name: 'Textarea' })
expect(textarea.props('size')).toBe('small')
// Check rows attribute in the DOM instead of props
const textareaElement = wrapper.find('textarea')
expect(textareaElement.attributes('rows')).toBe('6')
expect(textarea.classes()).toContain('text-xs')
expect(textarea.classes()).toContain('w-full')
})
it('disables textarea when readonly', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test', true)
// Readonly should prevent entering edit mode
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(false)
})
it('stops click and keydown event propagation in edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const clickSpy = vi.fn()
const keydownSpy = vi.fn()
wrapper.element.addEventListener('click', clickSpy)
wrapper.element.addEventListener('keydown', keydownSpy)
await textarea.trigger('click')
await textarea.trigger('keydown', { key: 'Enter' })
// Events should be stopped from propagating
expect(clickSpy).not.toHaveBeenCalled()
expect(keydownSpy).not.toHaveBeenCalled()
})
})
describe('Value Updates', () => {
it('emits update:modelValue when textarea content changes', async () => {
const widget = createMockWidget('# Original')
const wrapper = mountComponent(widget, '# Original')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Updated Content')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
})
it('renders updated HTML after value change and blur', async () => {
const widget = createMockWidget('# Original')
const wrapper = mountComponent(widget, '# Original')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('## New Heading\nWith **bold** text')
await textarea.trigger('input')
await blurTextarea(wrapper)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
})
it('emits update:modelValue for callback handling at parent level', async () => {
const widget = createMockWidget('# Test', {})
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
await textarea.trigger('input')
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('# Test', {}, undefined)
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
// Should not throw error and should still emit Vue event
await expect(textarea.trigger('input')).resolves.not.toThrow()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
})
describe('Complex Markdown Rendering', () => {
it('handles multiple markdown elements', () => {
const complexMarkdown = `# Main Heading
## Subheading
This paragraph has **bold** and *italic* text.
Another line with more content.`
const widget = createMockWidget(complexMarkdown)
const wrapper = mountComponent(widget, complexMarkdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<h1>Main Heading</h1>')
expect(displayDiv.html()).toContain('<h2>Subheading</h2>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
expect(displayDiv.html()).toContain('<em>italic</em>')
})
it('handles line breaks in markdown', () => {
const markdownWithBreaks = 'Line 1\nLine 2\nLine 3'
const widget = createMockWidget(markdownWithBreaks)
const wrapper = mountComponent(widget, markdownWithBreaks)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.html()).toContain('<br>')
})
it('handles empty or whitespace-only markdown', () => {
const whitespaceMarkdown = ' \n\n '
const widget = createMockWidget(whitespaceMarkdown)
const wrapper = mountComponent(widget, whitespaceMarkdown)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles very long markdown content', async () => {
const longMarkdown = '# Heading\n' + 'Lorem ipsum '.repeat(1000)
const widget = createMockWidget(longMarkdown)
const wrapper = mountComponent(widget, longMarkdown)
// Should render without issues
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.exists()).toBe(true)
// Should switch to edit mode
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
expect(textarea.element.value).toBe(longMarkdown)
})
it('handles special characters in markdown', async () => {
const specialChars = '# Special: @#$%^&*()[]{}|\\:";\'<>?,./'
const widget = createMockWidget(specialChars)
const wrapper = mountComponent(widget, specialChars)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(specialChars)
})
it('handles unicode characters', async () => {
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
const widget = createMockWidget(unicode)
const wrapper = mountComponent(widget, unicode)
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(unicode)
await textarea.setValue(unicode + ' more unicode')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
})
it('handles rapid edit mode toggling', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
// Rapid toggling
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
await blurTextarea(wrapper)
expect(wrapper.find('.comfy-markdown-content').exists()).toBe(true)
await clickToEdit(wrapper)
expect(wrapper.find('textarea').exists()).toBe(true)
})
})
describe('Styling and Layout', () => {
it('applies widget-markdown class to container', () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const container = wrapper.find('.widget-markdown')
expect(container.exists()).toBe(true)
expect(container.classes()).toContain('relative')
expect(container.classes()).toContain('w-full')
expect(container.classes()).toContain('cursor-text')
})
it('applies overflow handling to display mode', () => {
const widget = createMockWidget(
'# Long Content\n' + 'Content '.repeat(100)
)
const wrapper = mountComponent(
widget,
'# Long Content\n' + 'Content '.repeat(100)
)
const displayDiv = wrapper.find('.comfy-markdown-content')
expect(displayDiv.classes()).toContain('overflow-y-auto')
})
})
describe('Focus Management', () => {
it('creates textarea reference when entering edit mode', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
const vm = wrapper.vm as InstanceType<typeof WidgetMarkdown>
// Test that the component creates a textarea reference when entering edit mode
// @ts-expect-error - isEditing is not exposed
expect(vm.isEditing).toBe(false)
// @ts-expect-error - startEditing is not exposed
await vm.startEditing()
// @ts-expect-error - isEditing is not exposed
expect(vm.isEditing).toBe(true)
await wrapper.vm.$nextTick()
// Check that textarea exists after entering edit mode
const textarea = wrapper.findComponent({ name: 'Textarea' })
expect(textarea.exists()).toBe(true)
})
})
})

View File

@@ -1,538 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import TreeSelect from 'primevue/treeselect'
import type { TreeSelectProps } from 'primevue/treeselect'
import { describe, expect, it, vi } from 'vitest'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import WidgetTreeSelect, { type TreeNode } from './WidgetTreeSelect.vue'
const createTreeData = (): TreeNode[] => [
{
key: '0',
label: 'Documents',
data: 'Documents Folder',
children: [
{
key: '0-0',
label: 'Work',
data: 'Work Folder',
children: [
{
key: '0-0-0',
label: 'Expenses.doc',
data: 'Expenses Document',
leaf: true
},
{
key: '0-0-1',
label: 'Resume.doc',
data: 'Resume Document',
leaf: true
}
]
},
{
key: '0-1',
label: 'Home',
data: 'Home Folder',
children: [
{
key: '0-1-0',
label: 'Invoices.txt',
data: 'Invoices for this month',
leaf: true
}
]
}
]
},
{
key: '1',
label: 'Events',
data: 'Events Folder',
children: [
{ key: '1-0', label: 'Meeting', data: 'Meeting', leaf: true },
{
key: '1-1',
label: 'Product Launch',
data: 'Product Launch',
leaf: true
},
{
key: '1-2',
label: 'Report Review',
data: 'Report Review',
leaf: true
}
]
}
]
describe('WidgetTreeSelect Tree Navigation', () => {
const createMockWidget = (
value: WidgetValue = null,
options: Partial<TreeSelectProps> = {},
callback?: (value: WidgetValue) => void
): SimplifiedWidget<WidgetValue> => ({
name: 'test_treeselect',
type: 'object',
value,
options,
callback
})
const mountComponent = (
widget: SimplifiedWidget<WidgetValue>,
modelValue: WidgetValue,
readonly = false
) => {
return mount(WidgetTreeSelect, {
global: {
plugins: [PrimeVue],
components: { TreeSelect }
},
props: {
widget,
modelValue,
readonly
}
})
}
const setTreeSelectValueAndEmit = async (
wrapper: ReturnType<typeof mount>,
value: unknown
) => {
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
await treeSelect.vm.$emit('update:modelValue', value)
return treeSelect
}
describe('Component Rendering', () => {
it('renders treeselect component', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.exists()).toBe(true)
})
it('displays tree options from widget options', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(options)
})
it('displays initial selected value', () => {
const options = createTreeData()
const selectedValue = {
key: '0-0-0',
label: 'Expenses.doc',
data: 'Expenses Document',
leaf: true
}
const widget = createMockWidget(selectedValue, { options })
const wrapper = mountComponent(widget, selectedValue)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('modelValue')).toEqual(selectedValue)
})
it('applies small size styling', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('size')).toBe('small')
})
it('applies text-xs class', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.classes()).toContain('text-xs')
})
})
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
it('emits Vue event when selection is cleared', async () => {
const options = createTreeData()
const initialValue = { key: '0-0-0', label: 'Expenses.doc' }
const widget = createMockWidget(initialValue, { options })
const wrapper = mountComponent(widget, initialValue)
await setTreeSelectValueAndEmit(wrapper, null)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([null])
})
it('handles callback when widget value changes', async () => {
const mockCallback = vi.fn()
const options = createTreeData()
const widget = createMockWidget(null, { options }, mockCallback)
const wrapper = mountComponent(widget, null)
// Test that the treeselect has the callback widget
expect(widget.callback).toBe(mockCallback)
// Manually trigger the composable's onChange to test callback
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.exists()).toBe(true)
})
it('handles missing callback gracefully', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options }, undefined)
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-1-0', label: 'Invoices.txt' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
})
describe('Tree Structure Handling', () => {
it('handles flat tree structure', () => {
const flatOptions: TreeNode[] = [
{ key: 'item1', label: 'Item 1', leaf: true },
{ key: 'item2', label: 'Item 2', leaf: true },
{ key: 'item3', label: 'Item 3', leaf: true }
]
const widget = createMockWidget(null, { options: flatOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(flatOptions)
})
it('handles nested tree structure', () => {
const nestedOptions = createTreeData()
const widget = createMockWidget(null, { options: nestedOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(nestedOptions)
})
it('handles tree with mixed leaf and parent nodes', () => {
const mixedOptions: TreeNode[] = [
{ key: 'leaf1', label: 'Leaf Node', leaf: true },
{
key: 'parent1',
label: 'Parent Node',
children: [{ key: 'child1', label: 'Child Node', leaf: true }]
},
{ key: 'leaf2', label: 'Another Leaf', leaf: true }
]
const widget = createMockWidget(null, { options: mixedOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(mixedOptions)
})
it('handles deeply nested tree structure', () => {
const deepOptions: TreeNode[] = [
{
key: 'level1',
label: 'Level 1',
children: [
{
key: 'level2',
label: 'Level 2',
children: [
{
key: 'level3',
label: 'Level 3',
children: [{ key: 'level4', label: 'Level 4', leaf: true }]
}
]
}
]
}
]
const widget = createMockWidget(null, { options: deepOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(deepOptions)
})
})
describe('Selection Modes', () => {
it('handles single selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'single'
})
const wrapper = mountComponent(widget, null)
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
await setTreeSelectValueAndEmit(wrapper, selectedNode)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNode])
})
it('handles multiple selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'multiple'
})
const wrapper = mountComponent(widget, null)
const selectedNodes = [
{ key: '0-0-0', label: 'Expenses.doc' },
{ key: '1-0', label: 'Meeting' }
]
await setTreeSelectValueAndEmit(wrapper, selectedNodes)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toEqual([selectedNodes])
})
it('handles checkbox selection mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
selectionMode: 'checkbox'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('selectionMode')).toBe('checkbox')
})
})
describe('Readonly Mode', () => {
it('disables treeselect when readonly', () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('disabled')).toBe(true)
})
it('does not emit changes in readonly mode', async () => {
const options = createTreeData()
const widget = createMockWidget(null, { options })
const wrapper = mountComponent(widget, null, true)
// Try to emit a change (though the component should prevent it)
await setTreeSelectValueAndEmit(wrapper, { key: '0-0-0', label: 'Test' })
// The component will still emit the event, but the disabled prop should prevent interaction
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined() // The event is emitted but the TreeSelect should be disabled
})
})
describe('Widget Options Handling', () => {
it('passes through valid widget options', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
placeholder: 'Select a node...',
filter: true,
showClear: true,
selectionMode: 'single'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('placeholder')).toBe('Select a node...')
expect(treeSelect.props('filter')).toBe(true)
expect(treeSelect.props('showClear')).toBe(true)
expect(treeSelect.props('selectionMode')).toBe('single')
})
it('excludes panel-related props', () => {
const options = createTreeData()
const widget = createMockWidget(null, {
options,
inputClass: 'custom-input',
inputStyle: { color: 'red' },
panelClass: 'custom-panel'
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// These props should be filtered out by the widgetPropFilter
const inputClass = treeSelect.props('inputClass')
const inputStyle = treeSelect.props('inputStyle')
// Either undefined or null are acceptable as "excluded"
expect(inputClass == null).toBe(true)
expect(inputStyle == null).toBe(true)
expect(treeSelect.exists()).toBe(true)
})
it('handles empty options gracefully', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual([])
})
it('handles missing options gracefully', () => {
const widget = createMockWidget(null)
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
// Should not crash, options might be undefined
expect(treeSelect.exists()).toBe(true)
})
})
describe('Edge Cases', () => {
it('handles malformed tree nodes', () => {
const malformedOptions: unknown[] = [
{ key: 'empty', label: 'Empty Object' }, // Valid object to prevent issues
{ key: 'random', label: 'Random', randomProp: 'value' } // Object with extra properties
]
const widget = createMockWidget(null, {
options: malformedOptions as TreeNode[]
})
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(malformedOptions)
})
it('handles nodes with missing keys', () => {
const noKeyOptions = [
{ key: 'generated-1', label: 'No Key 1', leaf: true },
{ key: 'generated-2', label: 'No Key 2', leaf: true }
] as TreeNode[]
const widget = createMockWidget(null, { options: noKeyOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noKeyOptions)
})
it('handles nodes with missing labels', () => {
const noLabelOptions: TreeNode[] = [
{ key: 'key1', leaf: true },
{ key: 'key2', leaf: true }
]
const widget = createMockWidget(null, { options: noLabelOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(noLabelOptions)
})
it('handles very large tree structure', () => {
const largeTree: TreeNode[] = Array.from({ length: 100 }, (_, i) => ({
key: `node${i}`,
label: `Node ${i}`,
children: Array.from({ length: 10 }, (_, j) => ({
key: `node${i}-${j}`,
label: `Child ${j}`,
leaf: true
}))
}))
const widget = createMockWidget(null, { options: largeTree })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toHaveLength(100)
})
it('handles tree with circular references safely', () => {
// Create nodes that could potentially have circular references
const circularOptions: TreeNode[] = [
{
key: 'parent',
label: 'Parent',
children: [{ key: 'child1', label: 'Child 1', leaf: true }]
}
]
const widget = createMockWidget(null, { options: circularOptions })
expect(() => mountComponent(widget, null)).not.toThrow()
})
it('handles nodes with special characters', () => {
const specialCharOptions: TreeNode[] = [
{ key: '@#$%^&*()', label: 'Special Chars @#$%', leaf: true },
{
key: '{}[]|\\:";\'<>?,./`~',
label: 'More Special {}[]|\\',
leaf: true
}
]
const widget = createMockWidget(null, { options: specialCharOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(specialCharOptions)
})
it('handles unicode in node labels', () => {
const unicodeOptions: TreeNode[] = [
{ key: 'unicode1', label: '🌟 Unicode Star', leaf: true },
{ key: 'unicode2', label: '中文 Chinese', leaf: true },
{ key: 'unicode3', label: 'العربية Arabic', leaf: true }
]
const widget = createMockWidget(null, { options: unicodeOptions })
const wrapper = mountComponent(widget, null)
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
expect(treeSelect.props('options')).toEqual(unicodeOptions)
})
})
describe('Integration with Layout', () => {
it('renders within WidgetLayoutField', () => {
const widget = createMockWidget(null, { options: [] })
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.exists()).toBe(true)
expect(layoutField.props('widget')).toEqual(widget)
})
it('passes widget name to layout field', () => {
const widget = createMockWidget(null, { options: [] })
widget.name = 'custom_treeselect'
const wrapper = mountComponent(widget, null)
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
expect(layoutField.props('widget').name).toBe('custom_treeselect')
})
})
})

View File

@@ -25,15 +25,6 @@ import {
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
export type TreeNode = {
key: string
label?: string
data?: unknown
children?: TreeNode[]
leaf?: boolean
selectable?: boolean
}
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any

View File

@@ -469,8 +469,6 @@ const zSettings = z.object({
'Comfy.Minimap.RenderBypassState': z.boolean(),
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
'Comfy.Canvas.MouseWheelScroll': z.string(),
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),

View File

@@ -35,7 +35,6 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { type BaseDOMWidget, DOMWidgetImpl } from '@/scripts/domWidget'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
@@ -838,29 +837,22 @@ export class ComfyApp {
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
(e) => {
// Assertion: Not yet defined in litegraph.
const { newGraph } = e.detail
const nodeSet = new Set(newGraph.nodes)
const widgetStore = useDomWidgetStore()
const activeWidgets: Record<
string,
BaseDOMWidget<object | string>
> = Object.fromEntries(
newGraph.nodes
.flatMap((node) => node.widgets ?? [])
.filter((w) => w instanceof DOMWidgetImpl)
.map((w) => [w.id, w])
)
// Assertions: UnwrapRef
for (const { widget } of widgetStore.activeWidgetStates) {
if (!nodeSet.has(widget.node)) {
widgetStore.deactivateWidget(widget.id)
}
}
for (const [
widgetId,
widgetState
] of widgetStore.widgetStates.entries()) {
if (widgetId in activeWidgets) {
widgetState.active = true
widgetState.widget = activeWidgets[widgetId]
} else {
widgetState.active = false
for (const { widget } of widgetStore.inactiveWidgetStates) {
if (nodeSet.has(widget.node)) {
widgetStore.activateWidget(widget.id)
}
}
}

View File

@@ -1,4 +1,3 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -73,13 +72,6 @@ export const useExtensionService = () => {
}
})()
}
if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
onUserResolved((user) => {
void extension.onAuthUserResolved?.(user, app)
})
}
}
/**

View File

@@ -12,10 +12,10 @@ import {
LGraphEventMode,
LGraphNode,
LiteGraph,
type Point,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@/lib/litegraph/src/litegraph'
import type {
@@ -994,7 +994,7 @@ export const useLitegraphService = () => {
return node
}
function getCanvasCenter(): Point {
function getCanvasCenter(): Vector2 {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
return [x + w / dpi / 2, y + h / dpi / 2]

View File

@@ -1,4 +1,3 @@
import { useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -20,8 +19,6 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
const PREVIEW_REVOKE_DELAY_MS = 400
const createOutputs = (
filenames: string[],
type: ResultItemType,
@@ -43,26 +40,9 @@ interface SetOutputOptions {
export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore()
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
scheduledRevoke[locator]?.stop()
const { stop } = useTimeoutFn(() => {
delete scheduledRevoke[locator]
cb()
}, PREVIEW_REVOKE_DELAY_MS)
scheduledRevoke[locator] = { stop }
}
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
// Reactive state for node preview images - mirrors app.nodePreviewImages
const nodePreviewImages = ref<Record<string, string[]>>(
app.nodePreviewImages || {}
)
function getNodeOutputs(
node: LGraphNode
): ExecutedWsMessage['output'] | undefined {
@@ -216,12 +196,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -236,12 +212,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -253,9 +224,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function revokePreviewsByExecutionId(executionId: string) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
scheduleRevoke(nodeLocatorId, () =>
revokePreviewsByLocatorId(nodeLocatorId)
)
revokePreviewsByLocatorId(nodeLocatorId)
}
/**
@@ -273,7 +243,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
/**
@@ -290,7 +259,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
app.nodePreviewImages = {}
nodePreviewImages.value = {}
}
/**
@@ -325,7 +293,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
return hadOutputs
@@ -351,7 +318,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
removeNodeOutputs,
// State
nodeOutputs,
nodePreviewImages
nodeOutputs
}
})

View File

@@ -7,7 +7,3 @@ export type ApiKeyAuthHeader = {
}
export type AuthHeader = LoggedInAuthHeader | ApiKeyAuthHeader
export interface AuthUserInfo {
id: string
}

View File

@@ -7,7 +7,6 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyCommand } from '@/stores/commandStore'
import type { AuthUserInfo } from '@/types/authTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
type Widgets = Record<string, ComfyWidgetConstructor>
@@ -167,12 +166,5 @@ export interface ComfyExtension {
missingNodeTypes: MissingNodeType[]
): Promise<void> | void
/**
* Fired whenever authentication resolves, providing the anonymized user id..
* Extensions can register at any time and will receive the latest value immediately.
* This is an experimental API and may be changed or removed in the future.
*/
onAuthUserResolved?(user: AuthUserInfo, app: ComfyApp): Promise<void> | void
[key: string]: any
}

View File

@@ -37,8 +37,8 @@
</StepPanel>
<StepPanel v-slot="{ activateCallback }" value="1">
<InstallLocationPicker
v-model:install-path="installPath"
v-model:path-error="pathError"
v-model:installPath="installPath"
v-model:pathError="pathError"
/>
<div class="flex pt-6 justify-between">
<Button
@@ -58,8 +58,8 @@
</StepPanel>
<StepPanel v-slot="{ activateCallback }" value="2">
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
v-model:sourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
<div class="flex pt-6 justify-between">
<Button
@@ -78,13 +78,13 @@
</StepPanel>
<StepPanel v-slot="{ activateCallback }" value="3">
<DesktopSettingsConfiguration
v-model:auto-update="autoUpdate"
v-model:allow-metrics="allowMetrics"
v-model:autoUpdate="autoUpdate"
v-model:allowMetrics="allowMetrics"
/>
<MirrorsConfiguration
v-model:python-mirror="pythonMirror"
v-model:pypi-mirror="pypiMirror"
v-model:torch-mirror="torchMirror"
v-model:pythonMirror="pythonMirror"
v-model:pypiMirror="pypiMirror"
v-model:torchMirror="torchMirror"
:device="device"
class="mt-6"
/>

View File

@@ -155,11 +155,9 @@ LiteGraphGlobal {
"do_add_triggers_slots": false,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",
"macGesturesRequireMac": true,
"macTrackpadGestures": false,
"middle_click_slot_add_default_node": false,
"mouseWheelScroll": "panning",
"node_box_coloured_by_mode": false,
"node_box_coloured_when_on": false,
"node_images_path": "",

View File

@@ -56,13 +56,6 @@ vi.mock(
})
)
vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
useNodePreviewState: vi.fn(() => ({
latestPreviewUrl: computed(() => ''),
shouldShowPreviewImg: computed(() => false)
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -19,7 +19,7 @@ describe('migrateReroute', () => {
'single_connected.json',
'floating.json',
'floating_branch.json'
])('should correctly migrate %s', async (fileName) => {
])('should correctly migrate %s', (fileName) => {
// Load the legacy workflow
const legacyWorkflow = loadWorkflow(
`workflows/reroute/legacy/${fileName}`
@@ -29,9 +29,9 @@ describe('migrateReroute', () => {
const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow)
// Compare with snapshot
await expect(
JSON.stringify(migratedWorkflow, null, 2)
).toMatchFileSnapshot(`workflows/reroute/native/${fileName}`)
expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot(
`workflows/reroute/native/${fileName}`
)
})
})
})

View File

@@ -33,7 +33,6 @@
"src/types/**/*.d.ts",
"tests-ui/**/*",
"global.d.ts",
"eslint.config.ts",
"vite.config.mts",
".storybook/**/*"
]