Compare commits

...

64 Commits

Author SHA1 Message Date
Alexander Brown
ec15c44db4 fix: use satisfies Partial for shape-checked mocks in promotedWidgetRegistration tests
Replace unchecked as-unknown-as casts with satisfies Partial pattern

Remove redundant SubgraphNode & properties intersection type

Amp-Thread-ID: https://ampcode.com/threads/T-019c560a-9756-761c-b3a8-68774dc8362a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-13 00:14:13 -08:00
Alexander Brown
3cced08fa9 fix: resolve promoted widget slot with compressed target_slot
- resolveLegacyEntry now finds input by link ID instead of trusting target_slot

- navigateIntoSubgraph clicks title button instead of dblclick on body

Amp-Thread-ID: https://ampcode.com/threads/T-019c55f5-bf2b-7087-b0b8-0ac3be03c7c9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-13 00:06:29 -08:00
Alexander Brown
4f3f4cdcbf fix: use real types in widgetUtil tests
Replace inline type literals with INodeInputSlot and use satisfies Partial pattern for shape-checked mocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c55e6-e6e7-7280-be2d-053bf068868e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 23:44:41 -08:00
Alexander Brown
b0f8af5992 fix: address review issues in promoted widget slot system
- Fix memory leak: add dispose() to PromotedWidgetSlot, call from
  SubgraphNode.onRemoved() to clean up DOM adapters
- Replace self-assignment hack with explicit refreshPromotedWidgets()
  method (no-op on SubgraphNode, overridden per-instance after configure)
- Reconcile promoted widgets instead of destructive rebuild, reusing
  existing PromotedWidgetSlot instances to preserve transient state
- Wrap JSON.parse in proxyWidget.ts with try/catch for malformed data
- Add tests for dispose, reconciliation, and safe JSON parsing

Amp-Thread-ID: https://ampcode.com/threads/T-019c55b0-7cf1-7009-aa09-b12bc4773d28
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 23:25:19 -08:00
Alexander Brown
a1c9e913a8 fix: make widget.hidden reactive in Vue mode via WidgetValueStore
The PreviewAny toggle (Markdown/Plaintext) sets widget.hidden directly on the litegraph widget, but Vue mode read hidden from a non-reactive snapshot. Changed hidden from a plain property to a getter/setter backed by _state on BaseWidget, matching the pattern used by disabled and promoted.

Amp-Thread-ID: https://ampcode.com/threads/T-019c558b-3543-774c-90dc-5464d5dbf866
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 22:25:22 -08:00
Alexander Brown
0885ce742a fix: skip PromotedWidgetSlot when setting promoted flag in subgraph-opened handler
PromotedWidgetSlot overrides promoted as a getter-only property, causing a TypeError when the subgraph-opened handler tried to set it.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5585-480f-7523-96a7-f624a6689c1e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 21:47:27 -08:00
Alexander Brown
5fea1ec3a1 fix: clear computedDisabled on concrete widget during promoted slot drawing
The concrete widget created from the interior POJO inherits computedDisabled=true, which suppresses arrow buttons and display values for numeric widgets on the SubgraphNode.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5575-c485-767b-a7d1-adccefa4f60e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 21:40:09 -08:00
Alexander Brown
6073ba35a8 fix: prevent promoted textarea from being disabled on SubgraphNode
PromotedWidgetSlot now overrides computedDisabled to always return false, since the slot should remain interactive even though its associated input is internally linked.

PromotedDomWidgetAdapter proxy also intercepts computedDisabled to prevent the DOM widget from inheriting the interior widget's disabled state.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5575-c485-767b-a7d1-adccefa4f60e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 21:30:44 -08:00
Alexander Brown
6049332d4e refactor: replace widget copy with lightweight stub in SubgraphNode._setWidget
Replace the heavy createCopyForNode/Object.defineProperties pattern with
a minimal stub that only carries metadata (sourceNodeId, sourceWidgetName)
and delegates value/type/options to the interior widget.

- Remove BaseWidget and AssetWidget imports from SubgraphNode
- Trigger syncPromotedWidgets for live connections via proxyWidgets setter
- Patch input._widget references after stub-to-PromotedWidgetSlot replacement
- Resolve legacy -1 entries via subgraph input wiring instead of copy metadata
- Add optional slotName parameter to PromotedWidgetSlot constructor

Amp-Thread-ID: https://ampcode.com/threads/T-019c5551-e9c9-754a-afdd-94537f2542b3
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 21:23:51 -08:00
Alexander Brown
5de9eaccf4 refactor: remove unnecessary promotionList wrapper
Move null-guard into parseProxyWidgets and inline calls directly.

Delete promotionList.ts and its test file.

Amp-Thread-ID: https://ampcode.com/threads/T-019c554d-3032-771f-88e4-5ec40472504c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:44:03 -08:00
GitHub Action
dde845bbfd [automated] Apply ESLint and Oxfmt fixes 2026-02-13 04:42:28 +00:00
Alexander Brown
1154dec2ff refactor: replace PromotedDomWidgetAdapter class with Proxy
- Replace class with createPromotedDomWidgetAdapter factory using Proxy
- Proxy transparently delegates all properties to the inner widget,
  eliminating manual forwarding of element, component, inputSpec, props
- Extract widgetState getter to deduplicate WidgetValueStore lookups
- Move try/catch into resolve() instead of wrapping every call site
- Merge drawDisconnectedPlaceholder into draw() to reduce duplication
- Remove redundant resolvedType/resolvedOptions getters
- Remove redundant resolvedType assertions from tests

Amp-Thread-ID: https://ampcode.com/threads/T-019c5543-f50b-77a2-bca6-2549bdc15594
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:38:46 -08:00
Alexander Brown
e8af61e25d test: strengthen typing in promotionList tests
- Use `satisfies Partial<Omit<T, 'constructor'>>` for shape-checked mock
- Type mock param as NodeProperty instead of unknown
- Annotate test data with `satisfies ProxyWidgetsProperty`
- Document partial mock pattern in vitest-patterns.md

Amp-Thread-ID: https://ampcode.com/threads/T-019c5543-f50b-77a2-bca6-2549bdc15594
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:37:16 -08:00
Alexander Brown
c79e8a2251 Polishing... 2026-02-12 20:17:35 -08:00
Alexander Brown
7eac8f474b fix: guard widget removal in SubgraphNode removing-input handler
Check that the widget belongs to this node before attempting removal. During construction, SubgraphInput._widget can reference a widget from a different SubgraphNode instance, causing a 'Widget not found' error.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5519-3184-7414-a5cc-7c571d319cbc
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:56 -08:00
Alexander Brown
8d90f501e8 fix: slot-promoted widgets not appearing/removing mid-session
The input-connected event was dispatched before the link was created, so the handler could not resolve the interior node via linkIds. Pass the node directly in the event detail.

Widget disconnect used removeWidgetByName which failed for PromotedWidgetSlots (name mismatch). Use ensureWidgetRemoved with the direct widget reference instead.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5508-bf25-70e0-a48f-2663befeae98
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:54 -08:00
Alexander Brown
eb269aeee6 fix: unify slot-promoted widgets with PromotedWidgetSlot system
Replace -1 nodeId entries in proxyWidgets with real interior node IDs.
Slot-promoted widget copies from _setWidget() now carry sourceNodeId
and sourceWidgetName properties, enabling syncPromotedWidgets to
convert all entries uniformly to PromotedWidgetSlot instances.

- Add sourceNodeId/sourceWidgetName to _setWidget() copies
- Pass interiorNode through all _setWidget() call sites
- syncPromotedWidgets: convert legacy -1 entries and uncovered copies
  to PromotedWidgetSlots
- proxyWidgets getter: emit real IDs from copies
- Remove -1 special cases from TabSubgraphInputs and SubgraphEditor
- Remove redundant widgets_values restoration for -1 entries

Amp-Thread-ID: https://ampcode.com/threads/T-019c54e5-7ece-752b-8d00-c993fab9ee8e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:53 -08:00
Alexander Brown
75b5606890 fix: delegate slot-promoted widget value to interior widget
Replace Object.assign (which silently copies getter return values as data properties) with Object.defineProperties to properly install accessor pairs.

The value accessor delegates to the source interior widget, sharing the same store entry for both canvas and RSP rendering.

Reverts the PromotedWidgetSlot conversion approach in favor of this simpler fix.

Amp-Thread-ID: https://ampcode.com/threads/T-019c54cb-77f0-736a-a619-a530cdb8da86
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:52 -08:00
Alexander Brown
6140d22423 fix: sync input._widget refs and widgets_values for litegraph mode
After syncPromotedWidgets replaces copies with PromotedWidgetSlots, update input._widget references so litegraph lifecycle events (removal, renaming) target the correct instances.

Also fix widgets_values restoration to find slot-promoted widgets via input._widget when the PromotedWidgetSlot name differs from the slot name.

Amp-Thread-ID: https://ampcode.com/threads/T-019c54cb-77f0-736a-a619-a530cdb8da86
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:51 -08:00
Alexander Brown
69c4ab6c32 fix: unify slot-promoted widgets to use PromotedWidgetSlot
Slot-promoted widgets (connected via SubgraphInput) were using copies that had separate widgetValueStore entries from the interior widget, causing RSP values to be out of sync.

Resolve slot-promoted (-1) entries to their interior source node/widget and create PromotedWidgetSlot instances, identical to context-menu promoted widgets.

Amp-Thread-ID: https://ampcode.com/threads/T-019c54cb-77f0-736a-a619-a530cdb8da86
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:49 -08:00
Alexander Brown
b4a5462cbd fix: only show promoted widget purple border on source node
The purple outline was incorrectly shown on the SubgraphNode's promoted widget slots. It should only appear on the original node inside the subgraph where the widget was promoted from.

- PromotedWidgetSlot.promoted returns false

- PromotedDomWidgetAdapter.promoted returns false

- Suppress promoted border during delegated draw to concrete widget

Amp-Thread-ID: https://ampcode.com/threads/T-019c54b0-4da5-74d1-be45-5e713bb886f9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:48 -08:00
Alexander Brown
13d237d6c5 fix: sync computedHeight to interior widget before drawing promoted placeholder
After entering and exiting a subgraph, the interior widget's computedHeight

became stale, causing the placeholder rectangle to render at the wrong size

during SubgraphNode resize. Sync the slot's correctly allocated height to

the concrete widget before delegating the draw call.

Amp-Thread-ID: https://ampcode.com/threads/T-019c54a1-59f8-759b-8507-557c7dae83c6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:47 -08:00
Alexander Brown
ad4ee8dee0 fix: promote DOM widget textareas to subgraph node via adapter
Replace ProxyWidget with PromotedWidgetSlot and add
PromotedDomWidgetAdapter to wrap interior DOM widgets for display on
the SubgraphNode. The adapter overrides `node` and `y` so
DomWidgets.vue positions the textarea on the parent graph.

Fix litegraph:set-graph handler in app.ts that was setting adapter
widget states to active=false because adapters are not in any node's
widgets array. Now checks if the widget's host node is in the new
graph before deactivating.

Export NodeId from layout/types.ts and fix NodeId-to-string
conversions at Yjs and Map boundaries across the layout system.

Amp-Thread-ID: https://ampcode.com/threads/T-019c547f-15bf-716a-8abc-278dc9106c16
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:46 -08:00
Alexander Brown
5d0a6e2caa Make the SafeWidget reference the source node 2026-02-12 20:03:45 -08:00
Alexander Brown
8cfc3b5c02 fix: use WidgetValueStore consistently in PromotedWidgetSlot setters
Value and label setters now write through the store instead of the resolved interior widget, matching the getter pattern and eliminating implicit reliance on object identity between BaseWidget._state and the store.

Amp-Thread-ID: https://ampcode.com/threads/T-019c5400-4ecd-7668-bb96-b31233411f45
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:44 -08:00
Alexander Brown
18f0fde481 fix: address code review issues in promoted widget system
- Use ctx.translate instead of mutating concrete widget y/last_y in drawWidget

- Sync interior node input label in PromotedWidgetSlot.label setter

- Extract syncPromotedWidgets from side-effecting property setter

- Add idempotency guard to registerPromotedWidgetSlots

- Add tests for PromotedWidgetSlot, promotedWidgetRegistration, widgetUtil

Amp-Thread-ID: https://ampcode.com/threads/T-019c537f-f0da-77bc-a401-51c05a4e2ebb
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:43 -08:00
Alexander Brown
b403d7a134 fix: harden PromotedWidgetSlot and remove redundant subgraph widget mapping
- Wrap concrete drawWidget in try/finally to restore y/last_y on error
- Move Object.defineProperty from prototype to instance in constructor
- Replace raw fillText with drawTruncatingText for disconnected placeholder
- Remove redundant promotionList block in useGraphNodeManager
- Replace type assertion with String() for sourceNodeId

Amp-Thread-ID: https://ampcode.com/threads/T-019c5313-d2db-74ca-b912-6ed7452ec8ef
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:42 -08:00
Alexander Brown
9c7b45cb71 fix: prevent native widget loss and add isPromotedSlot discriminator
- Snapshot native widgets before filtering in proxyWidgets setter so
  native widgets included in the ordering list are preserved
- Add isPromotedSlot discriminator to BaseWidget (default false),
  overridden to true in PromotedWidgetSlot, replacing duck-typing
  check in SubgraphNode.onRemoved

Amp-Thread-ID: https://ampcode.com/threads/T-019c5121-c412-73a8-af7e-18f2c1a8a1b6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:41 -08:00
Alexander Brown
35f3d84b57 fix: PromotedWidgetSlot label setter, callback property, sourceNodeId passthrough
- Add label setter to PromotedWidgetSlot so renameWidget() doesn't throw
- Move callback from class method to constructor property for safe reassignment
- Pass sourceNodeId from PromotedWidgetSlot to getSharedWidgetEnhancements
- Add tests for all three fixes

Amp-Thread-ID: https://ampcode.com/threads/T-019c5105-89d8-717d-a12e-fcecfe27f947
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:40 -08:00
Alexander Brown
19c5b1c3b4 fix: delegate options to interior widget in PromotedWidgetSlot
The old ProxyWidget forwarded all property access including .options,

but PromotedWidgetSlot hardcoded options: {} in the constructor.

This broke widget config like step/min/max for promoted widgets.

Add resolvedOptions getter via defineProperty (same pattern as type)

and delete the own property set by BaseWidget constructor.

Amp-Thread-ID: https://ampcode.com/threads/T-019c50f7-6efa-7098-816b-9e97177f5aab
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:39 -08:00
GitHub Action
348b5ae909 [automated] Apply ESLint and Oxfmt fixes 2026-02-12 20:03:38 -08:00
Alexander Brown
3cabdc967b fix: replace as any with as unknown as SubgraphNode in promotionList test
Amp-Thread-ID: https://ampcode.com/threads/T-019c50b9-da3d-73ef-b0ed-731dce6ba59a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:37 -08:00
Alexander Brown
a3200d8bfe refactor: delete ProxyWidget infrastructure
Phase 6: Remove dead ProxyWidget code replaced by PromotedWidgetSlot system.

- Delete proxyWidget.ts (Proxy handler, isProxyWidget, registerProxyWidgets)

- Delete proxyWidget.test.ts

- Delete DisconnectedWidget.ts (only consumer was proxyWidget.ts)

- Clean JSDoc reference in promotedWidgetRegistration.ts

Amp-Thread-ID: https://ampcode.com/threads/T-019c5018-6d2c-7114-9ef9-c5dc35fcacac
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:36 -08:00
Alexander Brown
34bc7107ce refactor: canvas promoted widget rendering via PromotedWidgetSlot
Replace Proxy-based ProxyWidgets with PromotedWidgetSlot class for
canvas rendering of promoted subgraph widgets.

- Create PromotedWidgetSlot extending BaseWidget: plain class with
  store-backed value/type/label getters, delegated drawWidget/onClick
- Create registerPromotedWidgetSlots replacing registerProxyWidgets:
  same onConfigure pattern but creates PromotedWidgetSlot instances
- Update SubgraphNode.onRemoved: use 'sourceNodeId' in widget check
- Update app.ts: call registerPromotedWidgetSlots instead
- Add 16 unit tests for PromotedWidgetSlot

Amp-Thread-ID: https://ampcode.com/threads/T-019c5009-91b9-76b0-97f4-946caf1ba8a2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:35 -08:00
Alexander Brown
d557908e77 refactor: promotion logic uses store directly
Remove isProxyWidget/isDisconnectedWidget/_overlay from proxyWidgetUtils.ts,
widgetUtil.ts, and nodeDefStore.ts. Promotion logic now works purely with
the promotion list and WidgetValueStore.

- proxyWidgetUtils: getWidgetName returns w.name directly; pruneDisconnected
  uses getPromotionList + resolvePromotedWidget instead of filtering proxies
- widgetUtil: renameWidget simplified to work on actual widget instances;
  removed proxy unwrapping branch and parents parameter
- nodeDefStore: getInputSpecForWidget looks up promotion list to find
  interior node instead of reading _overlay from proxy widgets
- WidgetItem.vue: updated renameWidget call (removed parents arg)

Amp-Thread-ID: https://ampcode.com/threads/T-019c4fff-ef9d-740e-ab7c-397a59e0ddb6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:33 -08:00
Alexander Brown
87ccc07e6d refactor: RSP uses promotion metadata
Remove isProxyWidget from all Right Side Panel components.

- WidgetItem: sourceNodeName uses node directly (no proxy unwrap)

- WidgetActions: handleHideInput calls demoteWidget directly

- SectionWidgets: isWidgetShownOnParents uses widgetNode.id matching

- TabSubgraphInputs: resolves interior nodes from subgraph

Amp-Thread-ID: https://ampcode.com/threads/T-019c4ff7-3bf3-73d9-b886-49127760274f
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:32 -08:00
Alexander Brown
f02d6208c8 refactor: Vue rendering uses promotion list
- Remove isProxyWidget from useGraphNodeManager.ts

- getNodeType() accepts sourceNodeId instead of checking widget._overlay

- safeWidgetMapper() no longer special-cases ProxyWidgets

- extractVueNodeData() resolves promoted widgets via getPromotionList + resolvePromotedWidget

- Deduplicates against legacy ProxyWidgets still in node.widgets[] during transition

- NodeWidgets.vue unchanged (already reads from widgetValueStore)

Amp-Thread-ID: https://ampcode.com/threads/T-019c4fe4-1935-74dd-a8b3-021e2293b180
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:31 -08:00
Alexander Brown
d54054bb1e feat: widgetValueStore promotion resolution
Add resolvePromotedWidget() to WidgetValueStore for looking up interior
widget state by subgraph + nodeId + widgetName. Add getPromotionList()
helper as the single entry point for reading the promotion list from a
SubgraphNode's properties.proxyWidgets.

Phase 1 of ProxyWidget elimination — purely additive, no behavior changes.

Amp-Thread-ID: https://ampcode.com/threads/T-019c4fd4-6e4c-721f-8106-7b3f3cb93990
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:29 -08:00
Terry Jia
c52f48af45 feat(vueNodes): support resizing from all four corners (#8845)
## Summary
(Not sure we need this, and I don't know the reason why we only have one
cornor support previously, but it is requested by QA reporting in
Notion)

Add resize handles at all four corners (NW, NE, SW, SE) of Vue nodes,
matching litegraph's multi-corner resize behavior.

Vue nodes previously only supported resizing from the bottom-right (SE)
corner. This adds handles at all four corners with direction-aware delta
math, snap-to-grid support, and minimum size enforcement that keeps the
opposite corner anchored.
The content-driven minimum height is measured from the DOM at resize
start to prevent the node from sliding when dragged past its minimum
size.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c9d30d93-8243-4c44-a417-5ca6e9978b3b
2026-02-12 22:28:21 -05:00
AustinMroz
01cf3244b8 Fix hover state on collapsed bottom button (#8837)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0de0790a-e9f0-432c-9501-eae633e341b6"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/588221b0-b34a-4d35-8393-1bc1ec36c285"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8837-Fix-hover-state-on-collapsed-bottom-button-3056d73d36508139919fc1d750c6b135)
by [Unito](https://www.unito.io)
2026-02-12 18:35:37 -08:00
Terry Jia
0f33444eef fix: undo breaking Vue node image preview reactivity (#8839)
## Summary
restoreOutputs was assigning the same object reference to both
app.nodeOutputs and the Pinia reactive ref. This caused subsequent
writes via setOutputsByLocatorId to mutate the reactive proxy's target
through the raw reference before the proxy write, making Vue detect no
change and skip reactivity updates permanently.

Shallow-copy the outputs when assigning to the reactive ref so the proxy
target remains a separate object from app.nodeOutputs.

## Screenshots
before


https://github.com/user-attachments/assets/98f2b17c-87b9-41e7-9caa-238e36c3c032


after


https://github.com/user-attachments/assets/cb6e1d25-bd2e-41ed-a536-7b8250f858ec

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8839-fix-undo-breaking-Vue-node-image-preview-reactivity-3056d73d365081d2a1c7d4d9553f30e0)
by [Unito](https://www.unito.io)
2026-02-12 15:37:02 -05:00
pythongosssss
44ce9379eb Defer vue node layout calculations on hidden browser tabs (#8805)
## Summary

If you load the window in Nodes 2.0, then switch tabs while it is still
loading, the position of the nodes is calculated incorrectly due to
useElementBounding returning left=0, top=0 for the canvas element in a
hidden tab, causing clientPosToCanvasPos to miscalculate node positions
from the ResizeObserver measurements

## Changes

- **What**: 
- Store observed elements while document is in hidden state
- Re-observe when tab becomes visible

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8805-Defer-vue-node-layout-calculations-on-hidden-browser-tabs-3046d73d365081019ae6c403c0ac6d1a)
by [Unito](https://www.unito.io)
2026-02-12 11:48:20 -08:00
pythongosssss
138fa6a2ce Resolve issues with undo with Nodes 2.0 to fix link dragging/rendering (#8808)
## Summary

Resolves the following issue:
1. Enable Nodes 2.0
2. Load default workflow
3. Move any node e.g. VAE decode
4. Undo

All links go invisible, input/output slots no longer function

## Changes

- **What**
- Fixes slot layouts being deleted during undo/redo in
`handleDeleteNode`, which prevented link dragging from nodes after undo.
Vue patches (not remounts) components with the same key, so `onMounted`
never fires to re-register them - these were already being cleared up on
unmounted
- Fixes links disappearing after undo by clearing `pendingSlotSync` when
slot layouts already exist (undo/redo preserved them), rather than
waiting for Vue mounts that do not happen

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8808-Resolve-issues-with-undo-with-Nodes-2-0-to-fix-link-dragging-rendering-3046d73d3650818bbb0adf0104a5792d)
by [Unito](https://www.unito.io)
2026-02-12 11:46:49 -08:00
Comfy Org PR Bot
ce9d0ca670 1.40.1 (#8815)
Patch version increment to 1.40.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8815-1-40-1-3056d73d365081ed8adcf690bd07e1cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-12 02:48:38 -08:00
Terry Jia
6cf0357b3e fix(vueNodes): sync node size changes from extensions to Vue components (#7993)
## Summary
When extensions like KJNodes call node.setSize(), the Vue component now
properly updates its CSS variables to reflect the new size.

## Changes:
- LGraphNode pos/size setters now always sync to layoutStore with Canvas
source
- LGraphNode.vue listens to layoutStore changes and updates CSS
variables
- Fixed height calculation to account for NODE_TITLE_HEIGHT difference
- Removed _syncToLayoutStore flag (simplified - layoutStore ignores
non-existent nodes)
- Use setPos() helper method instead of direct pos[0]/pos[1] assignment

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/236a173a-e41d-485b-8c63-5c28ef1c69bf


after

https://github.com/user-attachments/assets/5fc3f7e4-35c7-40e1-81ac-38a35ee0ac1b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7993-fix-vueNodes-sync-node-size-changes-from-extensions-to-Vue-components-2e76d73d3650815799c5f2d9d8c7dcbf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-12 05:38:18 -05:00
Christian Byrne
c0c81dba49 fix: rename "Manage Extensions" label to "Extensions" (#8681)
## Summary

Rename the manager button label from "Manage Extensions" to "Extensions"
in both the `menu` and `commands` i18n sections.

## Changes

Updated `manageExtensions` value in `src/locales/en/main.json` (both
`menu` and `commands` sections).

- Fixes follow-up from #8644

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8681-fix-rename-Manage-Extensions-label-to-Extensions-2ff6d73d365081c9aef5c94f745299f6)
by [Unito](https://www.unito.io)
2026-02-12 18:36:07 +09:00
Jin Yi
553ea63357 [refactor] Migrate SettingDialog to BaseModalLayout design system (#8270) 2026-02-12 16:27:11 +09:00
Alexander Brown
995ebc4ba4 fix: default onboardingSurveyEnabled flag to false (#8829)
Fixes race condition where the onboarding survey could appear
intermittently before the actual feature flag value is fetched from the
server.

https://claude.ai/code/session_01EpCtunck6b89gpUFfWGKmZ

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8829-fix-default-onboardingSurveyEnabled-flag-to-false-3056d73d3650819b9f4cd0530efe8f39)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 23:13:12 -08:00
AustinMroz
d282353370 Add z-index to popover component (#8823)
This fixes the inability to see the value control popover in the
properties panel.
<img width="574" height="760" alt="image"
src="https://github.com/user-attachments/assets/c2cfa00f-f9b1-4c86-abda-830fd780d3f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8823-Add-z-index-to-popover-component-3056d73d365081e6b2dbe15517e4c4e0)
by [Unito](https://www.unito.io)
2026-02-11 21:33:05 -08:00
Simula_r
85ae0a57c3 feat: invite member upsell for single-seat plans (#8801)
## Summary

- Show an upsell dialog when single-seat plan users try to invite
members, with a banner on the members panel directing them to upgrade.
- Misc fixes for member max seat display

## Changes

- **What**: `InviteMemberUpsellDialogContent.vue`,
`MembersPanelContent.vue`, `WorkspacePanelContent.vue`

## Screenshots

<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/e39a23be-8533-4ebb-a4ae-2797fc382bc2"
/>
<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/bec55867-1088-4d3a-b308-5d5cce64c8ae"
/>



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8801-feat-invite-member-upsell-for-single-seat-plans-3046d73d365081349b09fe1d4dc572e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-11 21:15:59 -08:00
Terry Jia
0d64d503ec fix(vueNodes): propagate height to DOM widget children (#8821)
## Summary
DOM widgets using CSS background-image (e.g. KJNodes FastPreview)
collapsed to 0px height in vueNodes mode because background-image
doesn't contribute to intrinsic element size. Make WidgetDOM a flex
column container so mounted extension elements fill the available grid
row space.

## Screenshots (if applicable)
before 



https://github.com/user-attachments/assets/85de0295-1f7c-4142-ac15-1e813c823e3f


after


https://github.com/user-attachments/assets/824ab662-14cb-412d-93dd-97c0f549f992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8821-fix-vueNodes-propagate-height-to-DOM-widget-children-3056d73d36508134926dc67336fd0d70)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-11 23:05:54 -05:00
Alexander Brown
30ef6f2b8c Fix: Disabling textarea when linked. (#8818)
## Summary

Misaligned option setting when building the SimplifiedWidget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8818-Fix-Disabling-textarea-when-linked-3056d73d365081f581a9f1322aaf60bd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-11 18:45:52 -08:00
Alexander Brown
6012341fd1 Fix: Widgets Column sizing and ProgressText Widget (#8817)
## Summary

Fixes an issue where the label/control columns for widgets were
dependent on the contents of the progress text display widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8817-Fix-Widgets-Column-sizing-and-ProgressText-Widget-3056d73d36508141a714fe342c386eef)
by [Unito](https://www.unito.io)
2026-02-11 18:45:35 -08:00
Brian Jemilo II
a80f6d7922 Batch Drag & Drop Images (#8282)
## Summary

<!-- One sentence describing what changed and why. -->
Added feature to drag and drop multiple images into the UI and connect
them with a Batch Images node with tests to add convenience for users.
Only works with a group of images, mixing files not supported.

## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
I've updated our usage of Litegraph.createNode, honestly, that method is
pretty bad, onNodeCreated option method doesn't even return the node
created. I think I will probably go check out their repo to do a PR over
there. Anyways, I made a createNode method to avoid race conditions when
creating nodes for the paste actions. Will allow us to better
programmatically create nodes that do not have workflows that also need
to be connected to other nodes.

<!-- If this PR fixes an issue, uncomment and update the line below -->

https://www.notion.so/comfy-org/Implement-Multi-image-drag-and-drop-to-canvas-2eb6d73d36508195ad8addfc4367db10

## Screenshots (if applicable)

https://github.com/user-attachments/assets/d4155807-56e2-4e39-8ab1-16eda90f6a53

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8282-Batch-Drag-Drop-Images-2f16d73d365081c1ab31ce9da47a7be5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-02-11 17:39:41 -08:00
Johnpaul Chiwetelu
0f5aca6726 fix: set audio widget value after file upload (#8814)
## Summary

Set `audioWidget.value` after uploading an audio file so the combo
dropdown reflects the newly uploaded file.

## Changes

- **What**: Added `audioWidget.value = path` in the `uploadFile`
function before the callback call, matching the pattern used by the
image upload widget.

## Review Focus

One-line fix. The image upload widget already does this correctly in
`useImageUploadWidget.ts:86`. Without this line, the file uploads but
the dropdown doesn't update to show the selection.

Fixes #8800

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8814-fix-set-audio-widget-value-after-file-upload-3056d73d365081a0af90d4e096eb4975)
by [Unito](https://www.unito.io)
2026-02-11 17:00:28 -08:00
Johnpaul Chiwetelu
4fc1d2ef5b feat: No Explicit Any (#8601)
## Summary
- Add `typescript/no-explicit-any` rule to `.oxlintrc.json` to enforce
no explicit `any` types
- Fix all 40 instances of explicit `any` throughout the codebase
- Improve type safety with proper TypeScript types

## Changes Made

### Configuration
- Added `typescript/no-explicit-any` rule to `.oxlintrc.json`

### Type Fixes
- Replaced `any` with `unknown` for truly unknown types
- Updated generic type parameters to use `unknown` defaults instead of
`any`
- Fixed method `this` parameters to avoid variance issues
- Updated component props to match new generic types
- Fixed test mocks to use proper type assertions

### Key Files Modified
- `src/types/treeExplorerTypes.ts`: Updated TreeExplorerNode interface
generics
- `src/platform/settings/types.ts`: Fixed SettingParams generic default
- `src/lib/litegraph/src/LGraph.ts`: Fixed ParamsArray type constraint
- `src/extensions/core/electronAdapter.ts`: Fixed onChange callbacks
- `src/views/GraphView.vue`: Added proper type imports
- Multiple test files: Fixed type assertions and mocks

## Test Plan
- [x] All lint checks pass (`pnpm lint`)
- [x] TypeScript compilation succeeds (`pnpm typecheck`)
- [x] Pre-commit hooks pass
- [x] No regression in functionality

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8601-feat-add-typescript-no-explicit-any-rule-and-fix-all-instances-2fd6d73d365081fd9beef75d5a6daf5b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-12 00:13:48 +01:00
Benjamin Lu
92b7437d86 fix: scope manager button red dot to conflicts (#8810)
## Summary

Scope the top-menu Manager button red dot to manager conflict state
only, so release-update notifications do not appear on the Manager
button.

## Changes

- **What**:
- In `TopMenuSection`, remove release-store coupling and use only
`useConflictAcknowledgment().shouldShowRedDot` for the Manager button
indicator.
- Add a regression test in `TopMenuSection.test.ts` that keeps the
release red dot true while asserting the Manager button dot only appears
when the conflict red dot is true.

## Review Focus

- Confirm Manager button notification semantics are conflict-specific
and no longer mirror release notifications.
- Confirm the new test fails if release-store coupling is reintroduced.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8810-fix-scope-manager-button-red-dot-to-conflicts-3046d73d3650817887b9ca9c33919f48)
by [Unito](https://www.unito.io)
2026-02-11 15:00:25 -08:00
Simula_r
dd1fefe843 fix: credit display and top up and other UI display if personal membe… (#8784)
## Summary

Consolidate scattered role checks for credits, top-up, and subscribe
buttons into centralized workspace permissions (canTopUp,
canManageSubscription), ensuring "Add Credits" requires an active
subscription, subscribe buttons only appear when needed, and team
members see appropriately restricted billing UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8784-fix-credit-display-and-top-up-and-other-UI-display-if-personal-membe-3036d73d3650810fbc2de084f738943c)
by [Unito](https://www.unito.io)
2026-02-11 14:26:35 -08:00
Johnpaul Chiwetelu
adcb663b3e fix: link dragging offset on external monitors in Vue nodes mode (#8809)
## Summary

Fix link dragging offset when using Vue nodes mode on external monitors
with different DPI than the primary display.

## Changes

- **What**: Derive overlay canvas scale from actual canvas dimensions
(`canvas.width / canvas.clientWidth`) instead of
`window.devicePixelRatio`, fixing DPR mismatch. Map `LinkDirection.NONE`
to `'none'` in `convertDirection()` instead of falling through to
`'right'`.

## Before


https://github.com/user-attachments/assets/f5d04617-369f-4649-af60-11d31e27a75c



## After


https://github.com/user-attachments/assets/76434d2b-d485-43de-94f6-202a91f73edf


## Review Focus

The overlay canvas copies dimensions from the main canvas (which
includes DPR scaling from `resizeCanvas`). When the page loads on a
monitor whose DPR differs from what `resizeCanvas` used,
`window.devicePixelRatio` no longer matches the canvas's internal-to-CSS
ratio, causing all drawn link positions to be offset. The fix derives
scale directly from the canvas itself.

`LinkDirection.NONE = 0` is falsy, so it was caught by the `default`
case in `convertDirection()`, adding an unwanted directional curve to
moved input links.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8809-fix-link-dragging-offset-on-external-monitors-in-Vue-nodes-mode-3046d73d36508143b600f23f5fe07044)
by [Unito](https://www.unito.io)
2026-02-11 22:40:17 +01:00
AustinMroz
28b171168a New bottom button and badges (#8603)
- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors"
button now use a combined button design at the bottom of the node.
- A new "Errors" tab is added to the right side panel
- After a failed queue, the label of an invalid widget is now red.
- Badges other than price are now displayed on the bottom of the node.
- Price badge will now truncate from the first space, prioritizing the
sizing of the node title
- An indicator for the node resize handle is now displayed while mousing
over the node.

<img width="669" height="233" alt="image"
src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7"
/>


![resize](https://github.com/user-attachments/assets/e2473b5b-fe4d-4f1e-b1c3-57c23d2a0349)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 23:29:45 -08:00
Alexander Brown
69062c6da1 deps: Update vite (#8509)
## Summary

Update from beta.8 to beta.12

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8509-deps-Update-vite-2f96d73d3650814c96dbe14cdfb02151)
by [Unito](https://www.unito.io)
2026-02-10 22:42:26 -08:00
Alexander Brown
a7c2115166 feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 19:37:17 -08:00
Csongor Czezar
d044bed9b2 feat: use object info display_name as fallback before node name (#7622)
## Description
Implements fallback to object info display_name before using internal
node name when display_name is unavailable.

## Related Issue
Related to backend PR: comfyanonymous/ComfyUI#11340

## Changes
- Modified `getNodeDefs()` to use object info `display_name` before
falling back to `name`
- Added unit tests for display name fallback behavior

## Testing
- All existing tests pass
- Added 4 new unit tests covering various display_name scenarios

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7622-W-I-P-feat-use-object-info-display_name-as-fallback-before-node-name-2cd6d73d365081deb22fe5ed00e6dc2e)
by [Unito](https://www.unito.io)
2026-02-10 22:18:48 -05:00
Comfy Org PR Bot
d873c8048f 1.40.0 (#8797)
Minor version increment to 1.40.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8797-1-40-0-3046d73d36508109b4b7ed3f6ca4530e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 19:06:48 -08:00
232 changed files with 7696 additions and 3044 deletions

View File

@@ -96,6 +96,7 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

@@ -98,9 +98,6 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true

View File

@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').fill(value)
}
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').click()
}
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) {
return this.root.locator('nav').getByRole('button', { name })
}
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() {
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
await this.page
.getByTestId(TestIds.dialogs.about)
.waitFor({ state: 'visible' })
const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About'
})
await aboutButton.click()
await this.page.waitForSelector('.about-container')
}
}

View File

@@ -458,12 +458,13 @@ export class NodeReference {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
// Click the enter_subgraph title button (top-right of title bar).
// This is more reliable than dblclick on the node body because
// promoted DOM widgets can overlay the body and intercept events.
const buttonPos = {
x: nodePos.x + nodeSize.width - 15,
y: nodePos.y - titleHeight / 2
}
const checkIsInSubgraph = async () => {
return this.comfyPage.page.evaluate(() => {
@@ -473,20 +474,13 @@ export class NodeReference {
}
await expect(async () => {
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click({
position: buttonPos,
force: true
})
await this.comfyPage.nextFrame()
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return
}
if (await checkIsInSubgraph()) return
throw new Error('Not in subgraph yet')
}).toPass({ timeout: 5000, intervals: [100, 200, 500] })
}

View File

@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
})

View File

@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsContent = comfyPage.page.locator('.settings-content')
await expect(settingsContent).toBeVisible()
const isUsableHeight = await settingsContent.evaluate(
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator('.settings-container')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
// Open the keybinding tab
await comfyPage.page.getByLabel('Keybinding').click()
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Graph.DeduplicateSubgraphNodeIds',
true
)
})
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {

View File

@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -820,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
@@ -830,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
// Should still be in subgraph

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
name: 'TestSettingsExtension',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'TestHiddenSetting' as TestSettingId,
name: 'Test Hidden Setting',
type: 'hidden',
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Hidden']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestDeprecatedSetting' as TestSettingId,
name: 'Test Deprecated Setting',
type: 'text',
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Deprecated']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestVisibleSetting' as TestSettingId,
name: 'Test Visible Setting',
type: 'text',
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
await expect(dialog.searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
await dialog.searchBox.fill('Comfy')
await expect(dialog.searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
await dialog.searchBox.fill('test')
await expect(dialog.searchBox).toHaveValue('test')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
await dialog.searchBox.clear()
await expect(dialog.searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
expect(await dialog.categories.count()).toBeGreaterThan(0)
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Click on a specific category (Appearance) to verify category switching
const appearanceCategory = comfyPage.page.getByRole('option', {
name: 'Appearance'
})
await appearanceCategory.click()
const categoryCount = await dialog.categories.count()
// Verify the category is selected
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
})
if (categoryCount > 1) {
await dialog.categories.nth(1).click()
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
await expect(dialog.categories.nth(1)).toHaveClass(
/bg-interface-menu-component-surface-selected/
)
}
})
test('search functionality affects UI state', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
await dialog.searchBox.fill('graph')
await expect(dialog.searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
await expect(dialog.root).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
await dialog.searchBox.fill('a')
await dialog.searchBox.fill('ab')
await dialog.searchBox.fill('abc')
await dialog.searchBox.fill('abcd')
// Verify final value
await expect(searchBox).toHaveValue('abcd')
await expect(dialog.searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Hidden')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Deprecated')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Visible')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -61,6 +61,32 @@ vi.mock('@/services/myService', () => ({
}))
```
### Partial object mocks with `satisfies`
When mocking a class instance with only the properties your test needs, use
`satisfies Partial<Omit<T, 'constructor'>> as unknown as T`. This validates
the mock's shape against the real type while allowing the incomplete cast.
The `Omit<..., 'constructor'>` is needed because class types expose a
`constructor` property whose type (`LGraphNodeConstructor`, etc.) conflicts
with the plain object's `Function` constructor.
```typescript
// ✅ Shape-checked partial mock
function mockSubgraphNode(proxyWidgets?: NodeProperty) {
return {
properties: { proxyWidgets }
} satisfies Partial<
Omit<SubgraphNode, 'constructor'>
> as unknown as SubgraphNode
}
// ❌ Unchecked — typos and shape mismatches slip through
function mockSubgraphNode(proxyWidgets?: unknown): SubgraphNode {
return { properties: { proxyWidgets } } as unknown as SubgraphNode
}
```
### Configure mocks in tests
```typescript

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.12",
"version": "1.40.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -193,7 +193,7 @@
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
"vite": "catalog:"
}
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

556
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -19,7 +19,11 @@ import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -36,6 +40,36 @@ vi.mock('@/platform/distribution/types', () => ({
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -114,6 +148,7 @@ describe('TopMenuSection', () => {
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
@@ -330,4 +365,16 @@ describe('TopMenuSection', () => {
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
})

View File

@@ -145,7 +145,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -173,8 +172,6 @@ const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -236,10 +233,8 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
return shouldShowConflictRedDot.value
})
// Right side panel toggle

View File

@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const { t } = useI18n()
const isShortcutsTabActive = computed(() => {
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
settingsDialog.show('keybinding')
}
const closeBottomPanel = () => {

View File

@@ -12,9 +12,9 @@
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
@@ -36,15 +36,11 @@
</Tree>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
<script setup lang="ts" generic="T">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
import { computed, provide, ref } from 'vue'
import { computed, provide, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
@@ -60,6 +56,10 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
root: TreeExplorerNode
root: TreeExplorerNode<T>
class?: string
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
}>()
const {
@@ -83,19 +83,19 @@ const {
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
} = useTreeFolderOperations<T>(
/* expandNode */ (node: TreeExplorerNode<T>) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
if (node.getIcon) {
const icon = node.getIcon()
if (icon) {
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const fillNodeInfo = (
node: TreeExplorerNode<T>
): RenderedTreeExplorerNode<T> => {
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
}
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
emit('nodeClick', node, e)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
const node = menuTargetNode.value
return node?.contextMenuItems
? typeof node.contextMenuItems === 'function'
? node.contextMenuItems(node)
: node.contextMenuItems
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
n: RenderedTreeExplorerNode,
newName: string
) => {
const node = n as RenderedTreeExplorerNode<T>
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
const menuItems = computed<MenuItem[]>(() => {
const node = menuTargetNode.value
return [
getAddFolderMenuItem(node),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
if (node) {
renameCommand(node)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
visible: node?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
if (node) {
await deleteCommand(node)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
visible: node?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
})
: undefined
}))
)
})
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const handleContextMenu = (
e: MouseEvent,
node: RenderedTreeExplorerNode<T>
) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
const node = menuTargetNode.value
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (event: MenuItemCommandEvent) => Promise<void>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
node?.handleError
)
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
}
defineExpose({

View File

@@ -36,7 +36,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { computed, inject, ref } from 'vue'
@@ -53,17 +53,17 @@ import type {
} from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
}>()
const emit = defineEmits<{
(
e: 'itemDropped',
node: RenderedTreeExplorerNode,
data: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>,
data: RenderedTreeExplorerNode<T>
): void
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
}>()
const nodeBadgeText = computed<string>(() => {
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(props.node, newName)
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -117,9 +117,13 @@ if (props.node.droppable) {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData)
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
emit(
'itemDropped',
props.node,
dndData.data as RenderedTreeExplorerNode<T>
)
}
},
onDragEnter: (event) => {

View File

@@ -1,7 +1,7 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
size="md"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -4,12 +4,7 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"

View File

@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.WarnBlueprintOverwrite'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
}
const doNotAskAgain = ref(false)

View File

@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingModelsWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})

View File

@@ -80,7 +80,7 @@ import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -103,10 +103,7 @@ const handleGotItClick = () => {
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
}
const { missingNodePacks, isLoading, error } = useMissingNodes()

View File

@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
settingsDialog.show(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {

View File

@@ -1,9 +1,5 @@
<template>
<PanelTemplate
value="About"
class="about-container"
data-testid="about-panel"
>
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('g.about') }}
</h2>
@@ -32,7 +28,7 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore()
</script>

View File

@@ -1,13 +1,9 @@
<template>
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<DataTable
v-model:selection="selectedCommandData"
@@ -135,7 +131,7 @@
<i class="pi pi-replay" />
{{ $t('g.resetAll') }}
</Button>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
@@ -102,7 +102,7 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'

View File

@@ -6,14 +6,15 @@
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
</template>
@@ -27,7 +28,10 @@
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template>
</div>
@@ -166,7 +174,7 @@
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
@@ -206,14 +214,14 @@
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<Button
@@ -237,8 +245,29 @@
</template>
</template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites -->
<template v-else>
<template v-if="activeView === 'pending'">
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')

View File

@@ -1,21 +0,0 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex h-full w-full flex-col gap-2">
<slot name="header" />
<ScrollPanel class="h-0 grow pr-2">
<slot />
</ScrollPanel>
<slot name="footer" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import TabPanel from 'primevue/tabpanel'
const props = defineProps<{
value: string
class?: string
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="User" class="user-settings-container h-full">
<div class="user-settings-container h-full">
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
@@ -95,13 +95,12 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,11 +0,0 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<header class="mb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
@@ -8,44 +8,38 @@
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
</header>
<TabsRoot v-model="activeTab">
<div class="flex w-full items-center">
<TabList unstyled class="flex w-full gap-2">
<Tab
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
count: members.length
})
}}
</Tab>
</TabList>
</TabsTrigger>
</TabsList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
@@ -55,20 +49,22 @@
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
<i class="pi pi-plus text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
class="ml-2"
variant="secondary"
size="lg"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
@@ -76,17 +72,21 @@
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
<button
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
]"
type="button"
:disabled="!!item.disabled"
:class="
cn(
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
item.class,
item.disabled && 'pointer-events-auto cursor-not-allowed'
)
"
@click="
item.command?.({
originalEvent: $event,
@@ -96,44 +96,47 @@
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</button>
</template>
</Menu>
</template>
</div>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
@@ -144,19 +147,26 @@ const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
const activeTab = ref(defaultTab)
const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -187,11 +197,16 @@ const deleteTooltip = computed(() => {
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
@@ -231,7 +246,6 @@ const menuItems = computed(() => {
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -70,31 +70,17 @@
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
class="absolute right-3 top-2.5 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
<i
:class="
cn(
'pi size-4',
justCopied ? 'pi-check text-green-500' : 'pi-copy'
)
"
/>
</div>
</div>
</div>
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -130,6 +117,7 @@ const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -161,6 +149,10 @@ async function onCreateLink() {
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -1,32 +0,0 @@
<template>
<div>
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
v-if="isStaging"
value="staging"
severity="warn"
class="ml-2 text-xs"
/>
</h2>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>
.pi-cog {
font-size: 1.25rem;
margin-right: 0.5rem;
}
.version-tag {
margin-left: 0.5rem;
}
</style>

View File

@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -33,6 +35,7 @@ import {
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -87,10 +90,25 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -3,7 +3,6 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type {
LGraphGroup,
@@ -67,17 +66,6 @@ function isWidgetShownOnParents(
): boolean {
if (!parents.length) return false
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
// For proxy widgets (already promoted), check using overlay information
if (isProxyWidget(widget)) {
return proxyWidgets.some(
([nodeId, widgetName]) =>
widget._overlay.nodeId == nodeId &&
widget._overlay.widgetName === widgetName
)
}
// For regular widgets (not yet promoted), check using node ID and widget name
return proxyWidgets.some(
([nodeId, widgetName]) =>
widgetNode.id == nodeId && widget.name === widgetName

View File

@@ -15,7 +15,6 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -82,26 +81,13 @@ watch(
const widgetsList = computed((): NodeWidgetsList => {
const proxyWidgetsOrder = proxyWidgets.value
const { widgets = [] } = node
// Map proxyWidgets to actual proxy widgets in the correct order
const result: NodeWidgetsList = []
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
// Find the proxy widget that matches this nodeId and widgetName
const widget = widgets.find((w) => {
// Check if this is a proxy widget with _overlay
if (isProxyWidget(w)) {
return (
String(w._overlay.nodeId) === nodeId &&
w._overlay.widgetName === widgetName
)
}
// For non-proxy widgets (like linked widgets), match by name
return w.name === widgetName
})
if (widget) {
result.push({ node, widget })
}
const interiorNode = node.subgraph.getNodeById(nodeId)
if (!interiorNode) continue
const widget = interiorNode.widgets?.find((w) => w.name === widgetName)
if (widget) result.push({ node: interiorNode, widget })
}
return result
})

View File

@@ -4,7 +4,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import {
demoteWidget,
promoteWidget
@@ -57,32 +56,7 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
// For proxy widgets (already promoted), we need to find the original interior node and widget
if (isProxyWidget(widget)) {
const subgraph = parents[0].subgraph
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
return
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return
}
demoteWidget(interiorNode, originalWidget, parents)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)
}
demoteWidget(node, widget, parents)
canvasStore.canvas?.setDirty(true, true)
}

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -16,7 +15,6 @@ import {
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -63,14 +61,8 @@ const enhancedWidget = computed(() => {
})
const sourceNodeName = computed((): string | null => {
let sourceNode: LGraphNode | null = node
if (isProxyWidget(widget)) {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
return resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -83,10 +75,7 @@ const favoriteNode = computed(() =>
)
const widgetValue = computed({
get: () => {
widget.vueTrack?.()
return widget.value
},
get: () => widget.value,
set: (newValue: string | number | boolean | object) => {
emit('update:widgetValue', newValue)
}
@@ -103,7 +92,7 @@ const displayLabel = customRef((track, trigger) => {
const trimmedLabel = newValue.trim()
const success = renameWidget(widget, node, trimmedLabel, parents)
const success = renameWidget(widget, node, trimmedLabel)
if (success) {
canvasStore.canvas?.setDirty(true)

View File

@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { cn } from '@/utils/tailwindUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
const { t } = useI18n()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
// NODES settings
const showAdvancedParameters = computed({
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
}
function openFullSettings() {
dialogService.showSettingsDialog()
settingsDialog.show()
}
</script>

View File

@@ -70,11 +70,6 @@ const activeWidgets = computed<WidgetItem[]>({
if (!activeNode.value) return []
const node = activeNode.value
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
if (id === '-1') {
const widget = node.widgets.find((w) => w.name === name)
if (!widget) return []
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
}
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const widget = wNode.widgets.find((w) => w.name === name)
@@ -169,13 +164,27 @@ function showAll() {
widgets.push(...toAdd)
proxyWidgets.value = widgets
}
function getSlotPromotedKeys(node: SubgraphNode): Set<string> {
return new Set(
node.subgraph.inputNode.slots
.flatMap((slot) => slot.linkIds)
.flatMap((linkId) => {
const link = node.subgraph.getLink(linkId)
if (!link) return []
const { inputNode, input } = link.resolve(node.subgraph)
if (!inputNode || !input?.widget?.name) return []
return [`${inputNode.id}:${input.widget.name}`]
})
)
}
function hideAll() {
const node = activeNode.value
if (!node) return
const slotPromoted = getSlotPromotedKeys(node)
proxyWidgets.value = proxyWidgets.value.filter(
(propertyItem) =>
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
propertyItem[0] === '-1'
slotPromoted.has(`${propertyItem[0]}:${propertyItem[1]}`)
)
}
function showRecommended() {

View File

@@ -108,15 +108,14 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingPanelType } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
const menuItemStore = useMenuItemStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const managerState = useManagerState()
const settingStore = useSettingStore()
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
const showSettings = (defaultPanel?: SettingPanelType) => {
settingsDialog.show(defaultPanel)
}
const showManageExtensions = async () => {

View File

@@ -50,7 +50,8 @@
<template #before-label="{ node: treeNode }">
<span
v-if="
treeNode.data?.isModified || !treeNode.data?.isPersisted
(treeNode.data as ComfyWorkflow)?.isModified ||
!(treeNode.data as ComfyWorkflow)?.isPersisted
"
>*</span
>

View File

@@ -215,7 +215,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
}
)
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
interface TreeExplorerExposed {
addFolderCommand: (targetNodeKey: string) => void
}
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
defineExpose({
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
})

View File

@@ -63,7 +63,7 @@ onUnmounted(() => {
})
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
if (!expandedKeys) return
expandedKeys.value[node.key] = true
}

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -31,6 +31,15 @@ vi.mock('pinia')
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
hide: vi.fn(),
showAbout: vi.fn()
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSettingsDialog: mockShowSettingsDialog,
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))

View File

@@ -152,6 +152,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('subscription')
settingsDialog.show('subscription')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -55,63 +55,61 @@
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Show pricing table -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<!-- Credits Section -->
<Divider class="mx-0 my-2" />
</template>
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Add Credits (subscribed + personal or workspace owner only) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
<SubscribeButton
v-if="showSubscribeAction && isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<Button
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<Divider class="mx-0 my-2" />
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
@@ -222,16 +220,16 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
initState,
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const { permissions } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
@@ -242,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
@@ -275,22 +274,24 @@ const canUpgrade = computed(() => {
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
() => permissions.value.canManageSubscription
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
() => permissions.value.canManageSubscription && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
const showSubscribeAction = computed(
() =>
permissions.value.canManageSubscription &&
(!isActiveSubscription.value || isCancelled.value)
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
emit('close')
}
@@ -301,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -36,7 +36,7 @@ defineProps<{
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
@keydown.esc.capture="handleEscape"
>
<div
@@ -141,14 +141,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
const SIZE_CLASSES = {
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
} as const
type ModalSize = keyof typeof SIZE_CLASSES
const {
contentTitle,
rightPanelTitle,
size = 'lg'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
@@ -215,17 +232,3 @@ function handleEscape(event: KeyboardEvent) {
}
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
</style>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
Plan,
PreviewSubscribeResponse,
@@ -73,4 +74,5 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,25 +1,50 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
...(original as Record<string, unknown>),
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
useBillingPlans: () => ({
get plans() {
return mockPlans
},
currentPlanSlug: { value: null },
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
})
it('returns legacy type for personal workspace', () => {
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
})

View File

@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
getMaxSeats,
initialize,
fetchStatus,

View File

@@ -1,49 +1,117 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getSharedWidgetEnhancements,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
setActivePinia(createTestingPinia())
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const onReactivityUpdate = vi.fn()
watch(vueNodeData, onReactivityUpdate)
return [node, graph, onReactivityUpdate] as const
}
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
it('should trigger on callback', async () => {
const [node, , onReactivityUpdate] = createTestGraph()
node.widgets![0].callback!(2)
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should remain reactive after a connection is made', async () => {
const [node, graph, onReactivityUpdate] = createTestGraph()
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
return { node, graph, vueNodeData }
}
it('widget values are reactive through the store', async () => {
const { node } = createTestGraph()
const store = useWidgetValueStore()
const widget = node.widgets![0]
// Verify widget is a BaseWidget with correct value and node assignment
expect(widget).toBeInstanceOf(BaseWidget)
expect(widget.value).toBe(2)
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
const onValueChange = vi.fn()
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
widget.value = 42
await nextTick()
expect(widgetValue.value).toBe(42)
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: '1',
nodeId: String(node.id),
slotType: NodeSlotType.INPUT
})
await nextTick()
onReactivityUpdate.mockClear()
node.widgets![0].callback!(2)
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
node.widgets![0].value = 99
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(widgetValue.value).toBe(99)
})
})
describe('getSharedWidgetEnhancements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns nodeType when sourceNodeId is provided for a subgraph node', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('KSampler', 'KSampler')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(
subgraphNode,
widget,
String(interiorNode.id)
)
expect(result.nodeType).toBe('KSampler')
})
it('returns undefined nodeType when sourceNodeId is omitted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(subgraphNode, widget)
expect(result.nodeType).toBeUndefined()
})
})

View File

@@ -3,21 +3,17 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -30,7 +26,8 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
NodeId
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -41,19 +38,37 @@ export interface WidgetSlotMetadata {
linked: boolean
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
name: string
type: string
value: WidgetValue
borderStyle?: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
label?: string
/** Node type (for subgraph promoted widgets) */
nodeType?: string
options?: IWidgetOptions
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
}
@@ -95,23 +110,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -123,9 +121,9 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
}
}
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
function getNodeType(node: LGraphNode, sourceNodeId?: NodeId) {
if (!node.isSubgraphNode() || !sourceNodeId) return undefined
const subNode = node.subgraph.getNodeById(sourceNodeId)
return subNode?.type
}
@@ -133,45 +131,30 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
interface SharedWidgetEnhancements {
/** Reactive widget value that updates when the widget changes */
value: WidgetValue
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
label?: string
/** Widget options */
options?: IWidgetOptions
}
/**
* Extracts common widget enhancements shared across different rendering contexts.
* This function centralizes the logic for extracting metadata and reactive values
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
* This function centralizes the logic for extracting metadata from widgets.
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
widget: IBaseWidget,
sourceNodeId?: NodeId
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options as IWidgetOptions
nodeType: getNodeType(node, sourceNodeId)
}
}
@@ -212,8 +195,18 @@ function safeWidgetMapper(
): (widget: IBaseWidget) => SafeWidgetData {
return function (widget) {
try {
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const nodeId =
'sourceNodeId' in widget ? String(widget.sourceNodeId) : node.id
const widgetName =
'sourceWidgetName' in widget
? String(widget.sourceWidgetName)
: widget.name
const sharedEnhancements = getSharedWidgetEnhancements(
node,
widget,
nodeId
)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
@@ -228,20 +221,31 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
return {
name: widget.name,
nodeId,
name: widgetName,
type: widget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
options,
slotMetadata: slotInfo
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
type: widget.type || 'text'
}
}
}

View File

@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -1,5 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -8,12 +8,14 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
* Use this to handle folder operations in a tree.
* @param expandNode - The function to expand a node.
*/
export function useTreeFolderOperations(
expandNode: (node: RenderedTreeExplorerNode) => void
export function useTreeFolderOperations<T>(
expandNode: (node: RenderedTreeExplorerNode<T>) => void
) {
const { t } = useI18n()
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const newFolderNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const addFolderTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(
null
)
// Generate a unique temporary key for the new folder
const generateTempKey = (parentKey: string) => {
@@ -37,7 +39,7 @@ export function useTreeFolderOperations(
* The command to add a folder to a node via the context menu
* @param targetNode - The node where the folder will be added under
*/
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
const addFolderCommand = (targetNode: RenderedTreeExplorerNode<T>) => {
expandNode(targetNode)
newFolderNode.value = {
key: generateTempKey(targetNode.key),
@@ -49,13 +51,13 @@ export function useTreeFolderOperations(
totalLeaves: 0,
badgeText: '',
isEditingLabel: true
}
} as RenderedTreeExplorerNode<T>
addFolderTargetNode.value = targetNode
}
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
targetNode: RenderedTreeExplorerNode<T> | null
): MenuItem => {
return {
label: t('g.newFolder'),

View File

@@ -35,6 +35,7 @@ import {
} from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore'
@@ -73,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -582,7 +584,7 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.7',
category: 'view-controls' as const,
function: () => {
void dialogService.showSettingsDialog()
settingsDialog.show()
}
},
{
@@ -831,7 +833,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'About ComfyUI',
versionAdded: '1.6.4',
function: () => {
void dialogService.showSettingsDialog('about')
settingsDialog.showAbout()
}
},
{

View File

@@ -61,7 +61,7 @@ export function useFeatureFlags() {
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
)
},
get linearToggleEnabled() {

View File

@@ -7,8 +7,13 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
import { createNode, isImageNode } from '@/utils/litegraphUtil'
import {
cloneDataTransfer,
pasteImageNode,
pasteImageNodes,
usePaste
} from './usePaste'
function createMockNode() {
return {
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
it('should create new LoadImage node when no image node provided', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items
)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
it('should handle multiple image files', async () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
it('should do nothing when no image files present', async () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
it('should filter non-image items', async () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -183,21 +187,61 @@ describe('pasteImageNode', () => {
})
})
describe('pasteImageNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create multiple nodes for multiple files', async () => {
const mockNode1 = createMockNode()
const mockNode2 = createMockNode()
vi.mocked(createNode)
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const fileList = createDataTransfer([file1, file2]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const fileList = createDataTransfer([]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
usePaste()
@@ -207,7 +251,7 @@ describe('usePaste', () => {
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
@@ -312,3 +356,62 @@ describe('usePaste', () => {
})
})
})
describe('cloneDataTransfer', () => {
it('should clone string data', () => {
const original = new DataTransfer()
original.setData('text/plain', 'test text')
original.setData('text/html', '<p>test html</p>')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test text')
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
})
it('should clone files', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const original = createDataTransfer([file1, file2])
const cloned = cloneDataTransfer(original)
// Files are added from both .files and .items, causing duplicates
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
expect(Array.from(cloned.files)).toContain(file1)
expect(Array.from(cloned.files)).toContain(file2)
})
it('should preserve dropEffect and effectAllowed', () => {
const original = new DataTransfer()
original.dropEffect = 'copy'
original.effectAllowed = 'copyMove'
const cloned = cloneDataTransfer(original)
expect(cloned.dropEffect).toBe('copy')
expect(cloned.effectAllowed).toBe('copyMove')
})
it('should handle empty DataTransfer', () => {
const original = new DataTransfer()
const cloned = cloneDataTransfer(original)
expect(cloned.types.length).toBe(0)
expect(cloned.files.length).toBe(0)
})
it('should clone both string data and files', () => {
const file = createImageFile()
const original = createDataTransfer([file])
original.setData('text/plain', 'test')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test')
// Files are added from both .files and .items
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
expect(Array.from(cloned.files)).toContain(file)
})
})

View File

@@ -6,9 +6,41 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import {
createNode,
isAudioNode,
isImageNode,
isVideoNode
} from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
const persistent = new DataTransfer()
// Copy string data
for (const type of original.types) {
const data = original.getData(type)
if (data) {
persistent.setData(type, data)
}
}
for (const item of original.items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
persistent.items.add(file)
}
}
}
// Preserve dropEffect and effectAllowed
persistent.dropEffect = original.dropEffect
persistent.effectAllowed = original.effectAllowed
return persistent
}
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
)
}
export function pasteImageNode(
export async function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas
): Promise<LGraphNode | null> {
// No image node selected: add a new one
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
imageNode = await createNode(canvas, 'LoadImage')
}
pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []
for (const file of fileList) {
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)
if (imageNode) {
nodes.push(imageNode)
}
}
return nodes
}
/**
@@ -93,6 +135,7 @@ export const usePaste = () => {
const { graph } = canvas
let data: DataTransfer | string | null = e.clipboardData
if (!data) throw new Error('No clipboard data on clipboard event')
data = cloneDataTransfer(data)
const { items } = data
@@ -114,7 +157,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -32,15 +32,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
props: {
onClose: hide,
initialCategory
},
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-hidden h-full !py-0' },
root: {
style:
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
}
}
}
})
}

View File

@@ -1,20 +0,0 @@
import { computed } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
/**
* Returns whether the current environment is staging.
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export const isStaging = computed(() => {
if (!isCloud) {
return BUILD_TIME_IS_STAGING
}
const projectId = remoteConfig.value.firebase_config?.projectId
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
})

View File

@@ -9,7 +9,7 @@ import {
VramManagement
} from '@/types/serverArgs'
export type ServerConfigValue = string | number | true | null | undefined
export type ServerConfigValue = string | number | boolean | null | undefined
export interface ServerConfig<T> extends FormItem {
id: string
@@ -19,7 +19,7 @@ export interface ServerConfig<T> extends FormItem {
getValue?: (value: T) => Record<string, ServerConfigValue>
}
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
export const SERVER_CONFIG_ITEMS = [
// Network settings
{
id: 'listen',

View File

@@ -0,0 +1,74 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { generateUUID } from '@/utils/formatUtil'
import type { PromotedWidgetSlot } from './PromotedWidgetSlot'
/**
* Properties delegated to the PromotedWidgetSlot instead of the inner widget.
*/
type SlotManagedKey = 'y' | 'last_y' | 'computedHeight'
const SLOT_MANAGED = new Set<string>([
'y',
'last_y',
'computedHeight'
] satisfies SlotManagedKey[])
/**
* Creates a Proxy-based adapter that makes an interior DOM widget appear to
* belong to the SubgraphNode (host).
*
* `DomWidgets.vue` positions DOM widgets using `widget.node.pos` and
* `widget.y`. This proxy overrides those to reference the host node and the
* PromotedWidgetSlot's positional state, so the DOM element renders at the
* correct location on the parent graph.
*
* Only ONE of {adapter, interior widget} should be registered in
* `domWidgetStore` at a time.
*/
export function createPromotedDomWidgetAdapter<V extends object | string>(
inner: BaseDOMWidget<V>,
hostNode: LGraphNode,
slot: PromotedWidgetSlot
): BaseDOMWidget<V> & { readonly innerWidget: BaseDOMWidget<V> } {
const adapterId = generateUUID()
type Adapted = BaseDOMWidget<V> & { readonly innerWidget: BaseDOMWidget<V> }
return new Proxy(inner as Adapted, {
get(target, prop, receiver) {
switch (prop) {
case 'id':
return adapterId
case 'node':
return hostNode
case 'promoted':
case 'serialize':
case 'computedDisabled':
return false
case 'innerWidget':
return target
case 'isVisible':
return function isVisible() {
return !target.hidden && hostNode.isWidgetVisible(receiver)
}
}
if (SLOT_MANAGED.has(prop as string))
return (slot as IBaseWidget)[prop as SlotManagedKey]
return Reflect.get(target, prop, receiver)
},
set(target, prop, value) {
if (SLOT_MANAGED.has(prop as string)) {
const widget: IBaseWidget = slot
widget[prop as SlotManagedKey] = value
return true
}
return Reflect.set(target, prop, value)
}
})
}

View File

@@ -0,0 +1,599 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/lib/litegraph/src/widgets/widgetMap', () => ({
toConcreteWidget: vi.fn(() => null)
}))
function createMockSubgraphNode(
subgraphNodes: Record<string, LGraphNode> = {}
): SubgraphNode {
const subgraph = {
getNodeById: vi.fn((id: string) => subgraphNodes[id] ?? null)
} as unknown as LGraph
return {
subgraph,
isSubgraphNode: () => true,
id: 99,
type: 'graph/subgraph',
graph: {} as LGraph,
widgets: [],
inputs: [],
outputs: [],
pos: [0, 0],
size: [200, 100],
properties: {}
} as unknown as SubgraphNode
}
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
name: 'seed',
type: 'number',
value: 42,
options: {},
y: 0,
...overrides
} as IBaseWidget
}
describe('PromotedWidgetSlot', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('sets name from sourceNodeId and sourceWidgetName', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.name).toBe('5: seed')
expect(slot.sourceNodeId).toBe('5')
expect(slot.sourceWidgetName).toBe('seed')
})
it('is not promoted (purple border only shows on source node)', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.promoted).toBe(false)
})
it('has serialize set to false', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.serialize).toBe(false)
})
describe('resolve', () => {
it('resolves type from interior widget', () => {
const interiorWidget = createMockWidget({ type: 'number' })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.type).toBe('number')
})
it('returns button type when interior node is missing', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.type).toBe('button')
})
it('returns button type when interior widget is missing', () => {
const interiorNode = {
id: '5',
widgets: [createMockWidget({ name: 'other_widget' })]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.type).toBe('button')
})
})
describe('value', () => {
it('reads value from WidgetValueStore', () => {
const interiorWidget = createMockWidget()
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const store = useWidgetValueStore()
store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 12345,
options: {},
disabled: false,
promoted: true
})
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.value).toBe(12345)
})
it('returns undefined when widget state is not in store', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.value).toBeUndefined()
})
it('writes value to WidgetValueStore', () => {
const store = useWidgetValueStore()
const state = store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
disabled: false,
promoted: true
})
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.value = 99999
expect(state.value).toBe(99999)
})
})
describe('label', () => {
it('returns store label when available', () => {
const store = useWidgetValueStore()
store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
label: 'Custom Label',
disabled: false,
promoted: true
})
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.label).toBe('Custom Label')
})
it('falls back to name when store has no label', () => {
const store = useWidgetValueStore()
store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
disabled: false,
promoted: true
})
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.label).toBe('5: seed')
})
it('writes label to WidgetValueStore', () => {
const store = useWidgetValueStore()
const state = store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
disabled: false,
promoted: true
})
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = 'Renamed'
expect(state.label).toBe('Renamed')
})
it('clears label in WidgetValueStore when set to undefined', () => {
const store = useWidgetValueStore()
const state = store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
label: 'Old Label',
disabled: false,
promoted: true
})
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = undefined
expect(state.label).toBeUndefined()
})
it('updates the interior node input label when setting label', () => {
const store = useWidgetValueStore()
const state = store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
disabled: false,
promoted: true
})
const interiorInput = {
name: 'seed',
widget: { name: 'seed' },
label: undefined
}
const interiorNode = {
id: '5',
widgets: [createMockWidget()],
inputs: [interiorInput]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = 'Renamed'
expect(state.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
})
it('clears the interior node input label when label is set to undefined', () => {
const store = useWidgetValueStore()
const state = store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
label: 'Old',
disabled: false,
promoted: true
})
const interiorInput = {
name: 'seed',
widget: { name: 'seed' },
label: 'Old'
}
const interiorNode = {
id: '5',
widgets: [createMockWidget()],
inputs: [interiorInput]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.label = undefined
expect(state.label).toBeUndefined()
expect(interiorInput.label).toBeUndefined()
})
it('does not throw when setting label while disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(() => {
slot.label = 'Renamed'
}).not.toThrow()
})
})
describe('type and options accessors', () => {
it('defines type as an accessor on the instance, not the prototype', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const descriptor = Object.getOwnPropertyDescriptor(slot, 'type')
expect(descriptor).toBeDefined()
expect(descriptor!.get).toBeDefined()
})
it('type accessor returns resolved value even if BaseWidget data property existed', () => {
const interiorWidget = createMockWidget({ type: 'slider' })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
// Verify no own data property for 'type' exists (only accessor)
const descriptor = Object.getOwnPropertyDescriptor(slot, 'type')
expect(descriptor?.value).toBeUndefined()
expect(descriptor?.get).toBeDefined()
expect(slot.type).toBe('slider')
})
it('defines options as an accessor on the instance, not the prototype', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const descriptor = Object.getOwnPropertyDescriptor(slot, 'options')
expect(descriptor).toBeDefined()
expect(descriptor!.get).toBeDefined()
})
})
describe('options', () => {
it('delegates to interior widget options', () => {
const interiorWidget = createMockWidget({
options: { step: 10, min: 0, max: 100 }
})
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.options.step).toBe(10)
expect(slot.options.min).toBe(0)
expect(slot.options.max).toBe(100)
})
it('returns empty object when disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot.options).toEqual({})
})
})
describe('drawWidget', () => {
function createMockCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 50 })),
textAlign: '',
textBaseline: '',
fillStyle: '',
strokeStyle: '',
font: '',
globalAlpha: 1,
translate: vi.fn()
} as unknown as CanvasRenderingContext2D
}
it('uses drawTruncatingText for disconnected placeholder', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const spy = vi.spyOn(
slot as unknown as { drawTruncatingText: (...args: unknown[]) => void },
'drawTruncatingText'
)
const ctx = createMockCtx()
slot.drawWidget(ctx, { width: 200, showText: true })
expect(spy).toHaveBeenCalled()
})
it('clears computedDisabled on concrete widget before drawing', () => {
const interiorWidget = createMockWidget({ type: 'number' })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const concreteWidget = {
y: 0,
computedDisabled: true,
promoted: true,
drawWidget: vi.fn(function (this: { computedDisabled?: boolean }) {
expect(this.computedDisabled).toBe(false)
})
} as unknown as BaseWidget<IBaseWidget>
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
const ctx = createMockCtx()
slot.drawWidget(ctx, { width: 200, showText: true })
expect(concreteWidget.drawWidget).toHaveBeenCalled()
})
it('does not mutate concrete widget y/last_y during rendering', () => {
const interiorWidget = createMockWidget({ type: 'number' })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.y = 100
slot.last_y = 90
const originalY = 10
const originalLastY = 5
const concreteWidget = {
y: originalY,
last_y: originalLastY,
drawWidget: vi.fn()
} as unknown as BaseWidget<IBaseWidget>
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
const ctx = createMockCtx()
slot.drawWidget(ctx, { width: 200, showText: true })
// y/last_y should never have been mutated
expect(concreteWidget.y).toBe(originalY)
expect(concreteWidget.last_y).toBe(originalLastY)
// ctx.translate should be used instead of mutating widget state
expect(ctx.save).toHaveBeenCalled()
expect(ctx.translate).toHaveBeenCalledWith(0, slot.y - originalY)
expect(ctx.restore).toHaveBeenCalled()
})
it('does not mutate concrete widget y/last_y even when drawWidget throws', () => {
const interiorWidget = createMockWidget({ type: 'number' })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.y = 100
slot.last_y = 90
const concreteWidget = {
y: 10,
last_y: 5,
drawWidget: vi.fn(() => {
throw new Error('render failure')
})
} as unknown as BaseWidget<IBaseWidget>
vi.mocked(toConcreteWidget).mockReturnValueOnce(concreteWidget)
const ctx = createMockCtx()
expect(() =>
slot.drawWidget(ctx, { width: 200, showText: true })
).toThrow('render failure')
// Widget state was never mutated — ctx.translate is used instead
expect(concreteWidget.y).toBe(10)
expect(concreteWidget.last_y).toBe(5)
})
})
describe('onClick', () => {
it('does not throw when disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(() =>
slot.onClick({
e: {} as never,
node: subNode,
canvas: {} as never
})
).not.toThrow()
})
})
describe('callback', () => {
it('delegates to interior widget callback', () => {
const interiorCallback = vi.fn()
const interiorWidget = createMockWidget({ callback: interiorCallback })
const interiorNode = {
id: '5',
widgets: [interiorWidget]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.callback?.(42)
expect(interiorCallback).toHaveBeenCalledWith(
42,
undefined,
interiorNode,
undefined,
undefined
)
})
it('does not throw when disconnected', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(() => slot.callback?.(42)).not.toThrow()
})
it('can be reassigned as a property', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const customCallback = vi.fn()
slot.callback = customCallback
slot.callback?.(99)
expect(customCallback).toHaveBeenCalledWith(99)
})
})
describe('dispose', () => {
it('calls disposeDomAdapter', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
const spy = vi.spyOn(slot, 'disposeDomAdapter')
slot.dispose()
expect(spy).toHaveBeenCalled()
})
})
describe('_displayValue', () => {
it('returns string representation of value', () => {
const store = useWidgetValueStore()
store.registerWidget({
nodeId: '5',
name: 'seed',
type: 'number',
value: 42,
options: {},
disabled: false,
promoted: true
})
const interiorNode = {
id: '5',
widgets: [createMockWidget()]
} as unknown as LGraphNode
const subNode = createMockSubgraphNode({ '5': interiorNode })
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot._displayValue).toBe('42')
})
it('returns Disconnected when interior node is missing', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
expect(slot._displayValue).toBe('Disconnected')
})
it('is never computedDisabled (promoted slots stay interactive)', () => {
const subNode = createMockSubgraphNode()
const slot = new PromotedWidgetSlot(subNode, '5', 'seed')
slot.computedDisabled = true
expect(slot.computedDisabled).toBe(false)
})
})
})

View File

@@ -0,0 +1,305 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { isDOMWidget, isComponentWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { createPromotedDomWidgetAdapter } from './PromotedDomWidgetAdapter'
type WidgetValue = IBaseWidget['value']
/**
* A lightweight widget slot for canvas rendering of promoted subgraph widgets.
*
* Owns positional state (y, last_y, width) and delegates value/type/drawing
* to the resolved interior widget via the WidgetValueStore.
*
* When the interior node/widget no longer exists (disconnected state),
* it renders a "Disconnected" placeholder.
*/
export class PromotedWidgetSlot extends BaseWidget<IBaseWidget> {
override readonly isPromotedSlot = true
readonly sourceNodeId: NodeId
readonly sourceWidgetName: string
private readonly subgraphNode: SubgraphNode
/**
* When the interior widget is a DOM widget, this adapter is registered in
* `domWidgetStore` so that `DomWidgets.vue` positions the DOM element on the
* SubgraphNode rather than the interior node.
*/
private domAdapter?: BaseDOMWidget<object | string>
constructor(
subgraphNode: SubgraphNode,
sourceNodeId: NodeId,
sourceWidgetName: string,
slotName?: string
) {
const name = slotName ?? `${sourceNodeId}: ${sourceWidgetName}`
super(
{
name,
type: 'button',
value: undefined,
options: {},
y: 0,
serialize: false
},
subgraphNode
)
this.sourceNodeId = sourceNodeId
this.sourceWidgetName = sourceWidgetName
this.subgraphNode = subgraphNode
// BaseWidget constructor assigns `this.type` and `this.options` as own
// data properties. Override them with instance-level accessors that
// delegate to the resolved interior widget.
Object.defineProperty(this, 'type', {
get: () => this.resolve()?.widget.type ?? 'button',
configurable: true,
enumerable: true
})
Object.defineProperty(this, 'options', {
get: () => this.resolve()?.widget.options ?? {},
configurable: true,
enumerable: true
})
// The SubgraphNode's input slots are internally linked, which causes
// `updateComputedDisabled()` to set `computedDisabled = true` on all
// matching widgets. The promoted slot should always remain interactive.
Object.defineProperty(this, 'computedDisabled', {
get: () => false,
set: () => {},
configurable: true,
enumerable: true
})
this.callback = (value, canvas, _node, pos, e) => {
const resolved = this.resolve()
if (!resolved) return
resolved.widget.callback?.(value, canvas, resolved.node, pos, e)
}
this.syncDomAdapter()
}
/**
* Delegates to the interior widget's `computeLayoutSize` so that
* `_arrangeWidgets` treats this slot as a growable widget (e.g. textarea)
* and allocates the correct height on the SubgraphNode.
*
* Assigned dynamically in the constructor via `syncLayoutSize` because
* `computeLayoutSize` is an optional method on the base class — it must
* either exist or not exist, not return `undefined`.
*/
declare computeLayoutSize?: (node: LGraphNode) => {
minHeight: number
maxHeight?: number
minWidth: number
maxWidth?: number
}
/**
* Copies `computeLayoutSize` from the interior widget when it has one
* (e.g. textarea / DOM widgets), so `_arrangeWidgets` allocates the
* correct growable height on the SubgraphNode.
*/
private syncLayoutSize(): void {
const interiorWidget = this.resolve()?.widget
if (interiorWidget?.computeLayoutSize) {
this.computeLayoutSize = (node) => interiorWidget.computeLayoutSize!(node)
} else {
this.computeLayoutSize = undefined
}
}
private resolve(): {
node: LGraphNode
widget: IBaseWidget
} | null {
try {
const node = this.subgraphNode.subgraph.getNodeById(this.sourceNodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === this.sourceWidgetName)
if (!widget) return null
return { node, widget }
} catch {
// May fail during construction if the subgraph is not yet fully wired
// (e.g. in tests or during deserialization).
return null
}
}
private get widgetState() {
return useWidgetValueStore().getWidget(
stripGraphPrefix(this.sourceNodeId),
this.sourceWidgetName
)
}
override get value(): WidgetValue {
return this.widgetState?.value as WidgetValue
}
override set value(v: WidgetValue) {
const state = this.widgetState
if (!state) return
state.value = v
}
override get label(): string | undefined {
return this.widgetState?.label ?? this.name
}
override set label(v: string | undefined) {
const state = this.widgetState
if (!state) return
state.label = v
// Also sync the label on the corresponding input slot
const resolved = this.resolve()
const input = resolved?.node.inputs?.find(
(inp) => inp.widget?.name === this.sourceWidgetName
)
if (!input) return
input.label = v
}
override get promoted(): boolean {
return false
}
override get _displayValue(): string {
if (this.computedDisabled) return ''
if (!this.resolve()) return 'Disconnected'
const v = this.value
return v != null ? String(v) : ''
}
/**
* Creates or removes the DOM adapter based on whether the resolved interior
* widget is a DOM widget. Call after construction and whenever the interior
* widget might change (e.g. reconnection).
*
* Only one of {adapter, interior widget} is active in `domWidgetStore` at a
* time. The adapter is registered and the interior is deactivated, so
* `DomWidgets.vue` never mounts two `DomWidget.vue` instances for the same
* `HTMLElement`.
*/
syncDomAdapter(): void {
const resolved = this.resolve()
if (!resolved) return
const interiorWidget = resolved.widget
const isDom =
isDOMWidget(interiorWidget) || isComponentWidget(interiorWidget)
if (isDom && !this.domAdapter) {
const domWidget = interiorWidget as BaseDOMWidget<object | string>
const adapter = createPromotedDomWidgetAdapter(
domWidget,
this.subgraphNode,
this
)
this.domAdapter = adapter
const store = useDomWidgetStore()
// Start invisible — `updateWidgets()` will set `visible: true` on the
// first canvas draw when the SubgraphNode is in the current graph.
// This prevents a race where both adapter and interior DomWidget.vue
// instances try to mount the same HTMLElement during `onMounted`.
store.registerWidget(adapter, { visible: false })
} else if (!isDom && this.domAdapter) {
this.disposeDomAdapter()
}
this.syncLayoutSize()
}
/**
* Removes the DOM adapter from the store.
*/
disposeDomAdapter(): void {
if (!this.domAdapter) return
useDomWidgetStore().unregisterWidget(this.domAdapter.id)
this.domAdapter = undefined
}
/**
* Cleans up all resources held by this slot.
* Called when the SubgraphNode is removed from the graph.
*/
dispose(): void {
this.disposeDomAdapter()
}
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
// Lazily create the DOM adapter if it wasn't ready at construction time.
// During deserialization the interior widget may not exist yet when the
// PromotedWidgetSlot constructor runs, so syncDomAdapter() is retried here
// on every draw until it succeeds.
if (!this.domAdapter) {
this.syncDomAdapter()
}
const resolved = this.resolve()
const concrete = resolved
? toConcreteWidget(resolved.widget, resolved.node, false)
: null
if (concrete) {
// Suppress promoted border and disabled state: the purple outline and
// linked-disabled flag should only apply on the source node inside the
// subgraph, not on the SubgraphNode.
const wasPromoted = concrete.promoted
concrete.promoted = false
concrete.computedDisabled = false
concrete.computedHeight = this.computedHeight
ctx.save()
ctx.translate(0, this.y - concrete.y)
concrete.drawWidget(ctx, options)
ctx.restore()
concrete.promoted = wasPromoted
} else {
this.drawWidgetShape(ctx, options)
if (options.showText !== false) {
if (!resolved) ctx.fillStyle = LiteGraph.WIDGET_DISABLED_TEXT_COLOR
this.drawTruncatingText({
ctx,
...options,
leftPadding: 0,
rightPadding: 0
})
}
}
}
onClick(options: WidgetEventOptions): void {
const resolved = this.resolve()
if (!resolved) return
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
concrete?.onClick(options)
}
}

View File

@@ -0,0 +1,210 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { registerPromotedWidgetSlots } from './promotedWidgetRegistration'
vi.mock('@/core/graph/subgraph/proxyWidgetUtils', () => ({
promoteRecommendedWidgets: vi.fn()
}))
function createMockCanvas() {
return {
canvas: { addEventListener: vi.fn() },
setDirty: vi.fn()
} as unknown as LGraphCanvas
}
function createMockSubgraphNode(widgets: IBaseWidget[] = []): SubgraphNode {
const base = {
widgets,
inputs: [],
properties: { proxyWidgets: [] },
_setConcreteSlots: vi.fn(),
arrange: vi.fn()
} satisfies Partial<Omit<SubgraphNode, 'constructor' | 'isSubgraphNode'>>
return {
...base,
isSubgraphNode: () => true
} as unknown as SubgraphNode
}
describe('registerPromotedWidgetSlots', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('onConfigure syncPromotedWidgets', () => {
it('assigning to properties.proxyWidgets triggers widget reconstruction', () => {
const canvas = createMockCanvas()
registerPromotedWidgetSlots(canvas)
const nativeWidget = {
name: 'steps',
type: 'number',
value: 20,
options: {},
y: 0
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
const node = createMockSubgraphNode([nativeWidget])
const serialisedNode = {
properties: {}
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
// After onConfigure, proxyWidgets is a getter/setter property
const descriptor = Object.getOwnPropertyDescriptor(
node.properties,
'proxyWidgets'
)
expect(descriptor?.set).toBeDefined()
expect(descriptor?.get).toBeDefined()
// Assign promoted widgets via the setter
node.properties.proxyWidgets = [['42', 'seed']]
// The setter should have created a PromotedWidgetSlot
const promotedSlots = node.widgets.filter(
(w) => w instanceof PromotedWidgetSlot
)
expect(promotedSlots).toHaveLength(1)
expect(promotedSlots[0].sourceNodeId).toBe('42')
expect(promotedSlots[0].sourceWidgetName).toBe('seed')
// The setter should have called _setConcreteSlots and arrange
expect(node._setConcreteSlots).toHaveBeenCalled()
expect(node.arrange).toHaveBeenCalled()
})
it('preserves native widgets not in the proxy list', () => {
const canvas = createMockCanvas()
registerPromotedWidgetSlots(canvas)
const nativeWidget = {
name: 'steps',
type: 'number',
value: 20,
options: {},
y: 0
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
const node = createMockSubgraphNode([nativeWidget])
const serialisedNode = {
properties: {}
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
// Promote a different widget; native 'steps' should remain
node.properties.proxyWidgets = [['42', 'seed']]
const nativeWidgets = node.widgets.filter(
(w) => !(w instanceof PromotedWidgetSlot)
)
expect(nativeWidgets).toHaveLength(1)
expect(nativeWidgets[0].name).toBe('steps')
})
it('re-orders native widgets listed in the proxy list with id -1', () => {
const canvas = createMockCanvas()
registerPromotedWidgetSlots(canvas)
const nativeWidget = {
name: 'steps',
type: 'number',
value: 20,
options: {},
y: 0
} satisfies Partial<IBaseWidget> as unknown as IBaseWidget
const node = createMockSubgraphNode([nativeWidget])
const serialisedNode = {
properties: {}
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
// Use -1 to reference native widgets
node.properties.proxyWidgets = [['-1', 'steps']]
// Native widget should be placed via the proxy list ordering
expect(node.widgets).toHaveLength(1)
expect(node.widgets[0].name).toBe('steps')
expect(node.widgets[0]).toBe(nativeWidget)
})
it('reuses existing PromotedWidgetSlot instances on re-sync', () => {
const canvas = createMockCanvas()
registerPromotedWidgetSlots(canvas)
const node = createMockSubgraphNode()
const serialisedNode = {
properties: {}
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
// First sync: create a slot
node.properties.proxyWidgets = [['42', 'seed']]
const firstSlot = node.widgets.find(
(w) => w instanceof PromotedWidgetSlot
)
expect(firstSlot).toBeDefined()
// Second sync with same entry: should reuse the same instance
node.properties.proxyWidgets = [['42', 'seed']]
const secondSlot = node.widgets.find(
(w) => w instanceof PromotedWidgetSlot
)
expect(secondSlot).toBe(firstSlot)
})
it('disposes only removed slots during reconciliation', () => {
const canvas = createMockCanvas()
registerPromotedWidgetSlots(canvas)
const node = createMockSubgraphNode()
const serialisedNode = {
properties: {}
} satisfies Partial<ISerialisedNode> as unknown as ISerialisedNode
SubgraphNode.prototype.onConfigure!.call(node, serialisedNode)
// Create two slots
node.properties.proxyWidgets = [
['42', 'seed'],
['43', 'steps']
]
const slots = node.widgets.filter(
(w) => w instanceof PromotedWidgetSlot
) as PromotedWidgetSlot[]
expect(slots).toHaveLength(2)
const disposeSpy0 = vi.spyOn(slots[0], 'disposeDomAdapter')
const disposeSpy1 = vi.spyOn(slots[1], 'disposeDomAdapter')
// Remove only the second slot
node.properties.proxyWidgets = [['42', 'seed']]
// First slot should NOT have been disposed (reused)
expect(disposeSpy0).not.toHaveBeenCalled()
// Second slot should have been disposed (removed)
expect(disposeSpy1).toHaveBeenCalled()
// Only one promoted slot remains
const remaining = node.widgets.filter(
(w) => w instanceof PromotedWidgetSlot
)
expect(remaining).toHaveLength(1)
expect(remaining[0]).toBe(slots[0])
})
})
})

View File

@@ -0,0 +1,246 @@
import { PromotedWidgetSlot } from '@/core/graph/subgraph/PromotedWidgetSlot'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeId, NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
let registered = false
/**
* Registers the promoted widget system using PromotedWidgetSlot instances.
* Sets up:
* - `subgraph-opened` event: syncs `promoted` flags on interior widgets
* - `subgraph-converted` event: auto-promotes recommended widgets
* - `onConfigure` override: creates PromotedWidgetSlot instances in widgets[]
*
* Prototype patching is necessary because `onConfigure` must be set before
* SubgraphNode construction (called during `configure()` in the constructor).
*/
export function registerPromotedWidgetSlots(canvas: LGraphCanvas) {
if (registered) return
registered = true
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
const { subgraph, fromNode } = e.detail
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
for (const node of subgraph.nodes) {
for (const widget of node.widgets ?? []) {
if (widget instanceof PromotedWidgetSlot) continue
widget.promoted = proxyWidgets.some(
([n, w]) => node.id == n && widget.name == w
)
}
}
})
canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
)
SubgraphNode.prototype.onConfigure = onConfigure
}
/**
* Reconstructs the promoted widget slots on a subgraph node based on
* a serialized proxy widgets list.
*
* This replaces the previous side-effecting property setter pattern where
* assigning to `properties.proxyWidgets` would trigger widget reconstruction.
*/
function slotKey(nodeId: NodeId, widgetName: string): string {
return `${nodeId}:${widgetName}`
}
/**
* Resolves a legacy `-1` proxy entry to the actual interior node/widget
* by following the subgraph input wiring.
*/
function resolveLegacyEntry(
subgraphNode: SubgraphNode,
widgetName: string
): [string, string] | null {
const subgraph = subgraphNode.subgraph
const inputSlot = subgraph?.inputNode?.slots.find(
(s) => s.name === widgetName
)
if (!inputSlot || !subgraph) return null
const linkId = inputSlot.linkIds[0]
const link = linkId != null ? subgraph.getLink(linkId) : undefined
if (!link) return null
const inputNode = subgraph.getNodeById(link.target_id) ?? undefined
if (!inputNode) return null
// Find input by link ID rather than target_slot, since target_slot
// can be unreliable in compressed workflows.
const targetInput = inputNode.inputs?.find((inp) => inp.link === linkId)
const inputWidgetName = targetInput?.widget?.name
if (!inputWidgetName) return null
return [String(inputNode.id), inputWidgetName]
}
/**
* Reconciles the promoted widget slots on a subgraph node based on
* a serialized proxy widgets list.
*
* Reuses existing PromotedWidgetSlot instances when possible to preserve
* transient state (focus, DOM adapter, active input). Only creates new
* slots for entries that don't have an existing match, and disposes
* slots that are no longer needed.
*/
function syncPromotedWidgets(
node: LGraphNode & { isSubgraphNode(): boolean },
property: NodeProperty
): void {
const canvasStore = useCanvasStore()
const parsed = parseProxyWidgets(property)
const subgraphNode = node as SubgraphNode
const widgets = node.widgets ?? []
// Index existing PromotedWidgetSlots by key for O(1) lookup
const existingSlots = new Map<string, PromotedWidgetSlot>()
for (const w of widgets) {
if (w instanceof PromotedWidgetSlot) {
existingSlots.set(slotKey(w.sourceNodeId, w.sourceWidgetName), w)
}
}
// Collect stubs created by _setWidget() during configure
const stubs = widgets.filter(
(
w
): w is IBaseWidget & { sourceNodeId: string; sourceWidgetName: string } =>
!(w instanceof PromotedWidgetSlot) &&
'sourceNodeId' in w &&
'sourceWidgetName' in w
)
// Build the desired promoted slot list, reusing existing instances
const desired = new Set<string>()
const orderedSlots: IBaseWidget[] = []
for (const [nodeId, widgetName] of parsed) {
let resolvedNodeId = nodeId
let resolvedWidgetName = widgetName
if (nodeId === '-1') {
const resolved = resolveLegacyEntry(subgraphNode, widgetName)
if (!resolved) continue
;[resolvedNodeId, resolvedWidgetName] = resolved
}
const key = slotKey(resolvedNodeId, resolvedWidgetName)
if (desired.has(key)) continue
desired.add(key)
const existing = existingSlots.get(key)
if (existing) {
orderedSlots.push(existing)
} else {
orderedSlots.push(
new PromotedWidgetSlot(subgraphNode, resolvedNodeId, resolvedWidgetName)
)
}
}
// Promote stubs not covered by the parsed list
// (e.g. old workflows that didn't serialize slot-promoted entries)
for (const stub of stubs) {
const key = slotKey(stub.sourceNodeId, stub.sourceWidgetName)
if (desired.has(key)) continue
desired.add(key)
const existing = existingSlots.get(key)
if (existing) {
orderedSlots.unshift(existing)
} else {
orderedSlots.unshift(
new PromotedWidgetSlot(
subgraphNode,
stub.sourceNodeId,
stub.sourceWidgetName
)
)
}
}
// Dispose DOM adapters only on slots that are being removed
for (const [key, slot] of existingSlots) {
if (!desired.has(key)) {
slot.disposeDomAdapter()
}
}
// Rebuild widgets array: non-promoted widgets in original order, then promoted slots
node.widgets = widgets
.filter(
(w) =>
!(w instanceof PromotedWidgetSlot) &&
!(stubs as IBaseWidget[]).includes(w)
)
.concat(orderedSlots)
// Update input._widget references to point to PromotedWidgetSlots
// instead of stubs they replaced.
for (const input of subgraphNode.inputs) {
const oldWidget = input._widget
if (
!oldWidget ||
!('sourceNodeId' in oldWidget) ||
!('sourceWidgetName' in oldWidget)
)
continue
const sid = String(oldWidget.sourceNodeId)
const swn = String(oldWidget.sourceWidgetName)
const replacement = orderedSlots.find(
(w) =>
w instanceof PromotedWidgetSlot &&
w.sourceNodeId === sid &&
w.sourceWidgetName === swn
)
if (replacement) input._widget = replacement
}
canvasStore.canvas?.setDirty(true, true)
node._setConcreteSlots()
node.arrange()
}
const originalOnConfigure = SubgraphNode.prototype.onConfigure
const onConfigure = function (
this: LGraphNode,
serialisedNode: ISerialisedNode
) {
if (!this.isSubgraphNode())
throw new Error("Can't add promoted widgets to non-subgraphNode")
this.properties.proxyWidgets ??= []
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
get: () =>
this.widgets.map((w) => {
if (w instanceof PromotedWidgetSlot)
return [w.sourceNodeId, w.sourceWidgetName]
if ('sourceNodeId' in w && 'sourceWidgetName' in w)
return [String(w.sourceNodeId), String(w.sourceWidgetName)]
return ['-1', w.name]
}),
set: (value: NodeProperty) => syncPromotedWidgets(this, value)
})
this.refreshPromotedWidgets = () => {
this.properties.proxyWidgets = this.properties.proxyWidgets
}
if (serialisedNode.properties?.proxyWidgets) {
syncPromotedWidgets(this, serialisedNode.properties.proxyWidgets)
}
}

View File

@@ -1,141 +0,0 @@
import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
const canvasEl: Partial<HTMLCanvasElement> = { addEventListener() {} }
const canvas: Partial<LGraphCanvas> = { canvas: canvasEl as HTMLCanvasElement }
registerProxyWidgets(canvas as LGraphCanvas)
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
}
describe('Subgraph proxyWidgets', () => {
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
['1', 'stringWidget']
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [
['1', 'stringWidget'],
['2', 'stringWidget']
]
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).not.toEqual(
subgraphNode.widgets[1].name
)
})
test('Will serialize existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
proxyWidgets.push(['1', 'istringWidget'])
subgraphNode.properties.proxyWidgets = proxyWidgets
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Can detach and re-attach widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
expect(subgraphNode.widgets[0].value).toBe('value')
const poppedWidget = innerNodes[0].widgets.pop()
//simulate new draw frame
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe(undefined)
innerNodes[0].widgets.push(poppedWidget!)
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
const widget = innerNodes[0].widgets![0]
// Promote once
promoteWidget(innerNodes[0], widget, [subgraphNode])
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
// Try to promote again - should not create duplicate
promoteWidget(innerNodes[0], widget, [subgraphNode])
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
['1', 'stringWidget']
])
})
})

View File

@@ -1,248 +0,0 @@
import {
demoteWidget,
promoteRecommendedWidgets
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useLitegraphService } from '@/services/litegraphService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
/**
* @typedef {object} Overlay - Each proxy Widget has an associated overlay object
* Accessing a property which exists in the overlay object will
* instead result in the action being performed on the overlay object
* 3 properties are added for locating the proxied widget
* @property {LGraph} graph - The graph the widget resides in. Used for widget lookup
* @property {string} nodeId - The NodeId the proxy Widget is located on
* @property {string} widgetName - The name of the linked widget
*
* @property {boolean} isProxyWidget - Always true, used as type guard
* @property {LGraphNode} node - not included on IBaseWidget, but required for overlay
*/
type Overlay = Partial<IBaseWidget> & {
graph: LGraph
nodeId: string
widgetName: string
isProxyWidget: boolean
node?: LGraphNode
}
// A ProxyWidget can be treated like a normal widget.
// the _overlay property can be used to directly access the Overlay object
/**
* @typedef {object} ProxyWidget - a reference to a widget that can
* be displayed and owned by a separate node
* @property {Overlay} _overlay - a special property to access the overlay of the widget
* Any property that exists in the overlay will be accessed instead of the property
* on the linked widget
*/
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
}
export function isDisconnectedWidget(w: ProxyWidget) {
return w instanceof disconnectedWidget.constructor
}
export function registerProxyWidgets(canvas: LGraphCanvas) {
//NOTE: canvasStore hasn't been initialized yet
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
const { subgraph, fromNode } = e.detail
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
for (const node of subgraph.nodes) {
for (const widget of node.widgets ?? []) {
widget.promoted = proxyWidgets.some(
([n, w]) => node.id == n && widget.name == w
)
}
}
})
canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
)
SubgraphNode.prototype.onConfigure = onConfigure
}
const originalOnConfigure = SubgraphNode.prototype.onConfigure
const onConfigure = function (
this: LGraphNode,
serialisedNode: ISerialisedNode
) {
if (!this.isSubgraphNode())
throw new Error("Can't add proxyWidgets to non-subgraphNode")
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= []
originalOnConfigure?.call(this, serialisedNode)
Object.defineProperty(this.properties, 'proxyWidgets', {
get: () =>
this.widgets.map((w) =>
isProxyWidget(w)
? [w._overlay.nodeId, w._overlay.widgetName]
: ['-1', w.name]
),
set: (property: NodeProperty) => {
const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore()
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
if (isActiveGraph) {
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
if (nodeId === '-1') {
const widget = this.widgets.find((w) => w.name === widgetName)
return widget ? [widget] : []
}
const w = newProxyWidget(this, nodeId, widgetName)
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
this.widgets.push(...newWidgets)
canvasStore.canvas?.setDirty(true, true)
this._setConcreteSlots()
this.arrange()
}
})
if (serialisedNode.properties?.proxyWidgets) {
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
serialisedNode.widgets_values?.forEach((v, index) => {
if (parsed[index]?.[0] !== '-1') return
const widget = this.widgets.find((w) => w.name == parsed[index][1])
if (v !== null && widget) widget.value = v
})
}
}
function newProxyWidget(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
) {
const name = `${nodeId}: ${widgetName}`
const overlay = {
//items specific for proxy management
nodeId,
graph: subgraphNode.subgraph,
widgetName,
//Items which normally exist on widgets
afterQueued: undefined,
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
name,
node: subgraphNode,
onRemove: undefined,
promoted: undefined,
serialize: false,
width: undefined,
y: 0
}
return newProxyFromOverlay(subgraphNode, overlay)
}
function resolveLinkedWidget(
overlay: Overlay
): [LGraphNode | undefined, IBaseWidget | undefined] {
const { graph, nodeId, widgetName } = overlay
const n = getNodeByExecutionId(graph, nodeId)
if (!n) return [undefined, undefined]
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
//Slightly hacky. Force recursive resolution of nested widgets
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
widget.computedHeight = 20
return [n, widget]
}
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
if (overlay.widgetName.startsWith('$$')) {
overlay.node = new Proxy(subgraphNode, {
get(_t, p) {
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
if (!linkedNode) return []
return linkedNode.imgs
}
})
}
/**
* A set of handlers which define widget interaction
* Many arguments are shared between function calls
* @param {IBaseWidget} _t - The "target" the call is originally made on.
* This argument is never used, but must be defined for typechecking
* @param {string} property - The name of the accessed value.
* Checked for conditional logic, but never changed
* @param {object} receiver - The object the result is set to
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
else if (property == 'value') redirectedReceiver = backingWidget
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
}
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown) {
let redirectedTarget: object = backingWidget
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {
updatePreviews(linkedNode)
}
if (linkedNode && linkedWidget?.computedDisabled) {
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
}
//update linkage regularly, but no more than once per frame
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget
}
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
}
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
},
getPrototypeOf() {
return Reflect.getPrototypeOf(backingWidget)
},
ownKeys() {
return Reflect.ownKeys(backingWidget)
},
has(_t: IBaseWidget, property: string) {
let redirectedTarget: object = backingWidget
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
}
return Reflect.has(redirectedTarget, property)
}
}
const w = new Proxy(disconnectedWidget, handler)
return w
}

View File

@@ -1,9 +1,5 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import {
isProxyWidget,
isDisconnectedWidget
} from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -13,6 +9,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -57,15 +54,20 @@ export function demoteWidget(
widget.promoted = false
}
function getWidgetName(w: IBaseWidget): string {
return w.name
}
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
return ([n, w]: WidgetItem) =>
n.id == nodeId && getWidgetName(w) === widgetName
}
export function matchesPropertyItem([n, w]: WidgetItem) {
return ([nodeId, widgetName]: [string, string]) =>
n.id == nodeId && w.name === widgetName
n.id == nodeId && getWidgetName(w) === widgetName
}
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, w.name]
return [`${n.id}`, getWidgetName(w)]
}
function getParentNodes(): SubgraphNode[] {
@@ -176,8 +178,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
.filter(isProxyWidget)
.filter((w) => !isDisconnectedWidget(w))
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
const { resolvePromotedWidget } = useWidgetValueStore()
const promotionList = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
subgraphNode.properties.proxyWidgets = promotionList.filter(
([nodeId, widgetName]) =>
resolvePromotedWidget(subgraphNode.subgraph, nodeId, widgetName) !== null
)
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import { parseProxyWidgets } from './proxyWidget'
describe('parseProxyWidgets', () => {
it('returns empty array for null/undefined', () => {
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
expect(parseProxyWidgets(undefined)).toEqual([])
})
it('parses valid JSON string', () => {
const input = JSON.stringify([['widget1', 'target1']])
expect(parseProxyWidgets(input)).toEqual([['widget1', 'target1']])
})
it('passes through valid arrays', () => {
const input = [
['widget1', 'target1'],
['widget2', 'target2']
]
expect(parseProxyWidgets(input)).toEqual(input)
})
it('returns empty array for malformed JSON strings', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseProxyWidgets('{not valid json')).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to parse proxyWidgets property as JSON:',
'{not valid json'
)
warnSpy.mockRestore()
})
it('throws for invalid structure (valid JSON but wrong shape)', () => {
expect(() => parseProxyWidgets([['only_one']])).toThrow(
'Invalid assignment for properties.proxyWidgets'
)
expect(() => parseProxyWidgets({ key: 'value' })).toThrow(
'Invalid assignment for properties.proxyWidgets'
)
})
})

View File

@@ -9,10 +9,16 @@ export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (property == null) return []
if (typeof property === 'string') {
try {
property = JSON.parse(property)
} catch {
console.warn('Failed to parse proxyWidgets property as JSON:', property)
return []
}
}
const result = proxyWidgetsPropertySchema.safeParse(property)
if (result.success) return result.data
const error = fromZodError(result.error)

View File

@@ -19,7 +19,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
const toastStore = useToastStore()
const { staticUrls, buildDocsUrl } = useExternalLink()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
const onChangeRestartApp = (newValue: unknown, oldValue: unknown) => {
// Add a delay to allow changes to take effect before restarting.
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp('Restart ComfyUI to apply changes.', 1500)

View File

@@ -1412,7 +1412,7 @@ export class GroupNodeHandler {
handlerGroupData.oldToNewWidgetMap[Number(n)]?.[w]
const widget = this.widgets.find((wg) => wg.name === widgetName)
if (widget) {
widget.type = 'hidden'
widget.hidden = true
widget.computeSize = () => [0, -4]
}
}

View File

@@ -19,14 +19,14 @@ useExtensionService().registerExtension({
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview',
'preview_markdown',
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview',
'preview_text',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
@@ -48,6 +48,7 @@ useExtensionService().registerExtension({
showValueWidgetPlain.options.hidden = value
}
showValueWidget.label = 'Preview'
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
@@ -55,6 +56,7 @@ useExtensionService().registerExtension({
showValueWidget.element.disabled = true
showValueWidget.serialize = false
showValueWidgetPlain.label = 'Preview'
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
@@ -71,7 +73,7 @@ useExtensionService().registerExtension({
: onExecuted.apply(this, [message])
const previewWidgets =
this.widgets?.filter((w) => w.name === 'preview') ?? []
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''

View File

@@ -1,3 +1,4 @@
import type { ComfyExtension } from '@/types/comfy'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '../../scripts/app'
@@ -5,10 +6,17 @@ import { ComfyWidgets } from '../../scripts/widgets'
// Adds defaults for quickly adding nodes with middle click on the input/output
interface SlotDefaultsExtension extends ComfyExtension {
suggestionsNumber: { value: number } | null
slot_types_default_out: Record<string, string[]>
slot_types_default_in: Record<string, string[]>
setDefaults(maxNum?: number | null): void
}
app.registerExtension({
name: 'Comfy.SlotDefaults',
suggestionsNumber: null,
init() {
init(this: SlotDefaultsExtension) {
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
@@ -24,13 +32,13 @@ app.registerExtension({
},
defaultValue: 5,
onChange: (newVal) => {
this.setDefaults(newVal)
this.setDefaults(newVal as number)
}
})
},
slot_types_default_out: {},
slot_types_default_in: {},
async beforeRegisterNodeDef(nodeType, nodeData) {
async beforeRegisterNodeDef(this: SlotDefaultsExtension, nodeType, nodeData) {
var nodeId = nodeData.name
const inputs = nodeData['input']?.['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
for (const inputKey in inputs) {
@@ -83,22 +91,23 @@ app.registerExtension({
}
}
var maxNum = this.suggestionsNumber.value
var maxNum = this.suggestionsNumber?.value
this.setDefaults(maxNum)
},
setDefaults(maxNum?: number | null) {
setDefaults(this: SlotDefaultsExtension, maxNum?: number | null) {
LiteGraph.slot_types_default_out = {}
LiteGraph.slot_types_default_in = {}
const max = maxNum ?? undefined
for (const type in this.slot_types_default_out) {
LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[
type
].slice(0, maxNum)
].slice(0, max)
}
for (const type in this.slot_types_default_in) {
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
type
].slice(0, maxNum)
].slice(0, max)
}
}
})

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