Compare commits

...

76 Commits

Author SHA1 Message Date
DrJKL
5ff5ef14c7 refactor: extract shared widget draw primitives into a pure module
Move the canvas draw primitives (widget shape, truncating label/value text,
stepped arrow buttons) and the layout constants out of BaseWidget into a
this-free widgetDraw.ts that operates on widget data + theme. BaseWidget and
BaseSteppedWidget now delegate to these; widgetBehavior reuses the shared
visual resolver. Single source of truth shared by the class hierarchy and the
type-keyed behavior registry. No behavior change.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-14 02:45:35 -07:00
DrJKL
b7af994fc3 refactor: add type-keyed widget behavior registry for Vue-only widgets
Introduce a `type -> { drawWidget, onClick }` registry of pure functions over
widget data (widgetBehavior.ts), the ECS seam that replaces subclass-bound
canvas behavior. The Vue-only placeholder draw/click move there, resolving
visual/theme values from widget data via a small resolver instead of BaseWidget
getters. LGraphNode.drawWidgets consults the registry before falling back to
toConcreteWidget; VueOnlyWidget delegates to the shared behavior. No change to
toConcreteWidget or WidgetTypeMap.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-14 02:22:01 -07:00
DrJKL
a7dad79f75 refactor: collapse 14 Vue-only widget classes into one placeholder
The boundingbox/curve/imagecrop/painter/range/textarea/treeselect/chart/
fileupload/galleria/imagecompare/markdown/multiselect/selectbutton widgets
were identical BaseWidget subclasses: a `type` discriminant, a "Vue only"
draw notice, and a no-op onClick. Replace them with a single generic
VueOnlyWidget carrying the type via its component data, removing 13 modules.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-14 01:05:06 -07:00
DrJKL
2594c263f2 refactor: move widget computedDisabled into the frame-stable cache
Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-14 00:56:43 -07:00
DrJKL
8170f51ae0 chore(deps): update oxc toolchain (oxlint/oxfmt/tsgolint) to 1.69
Bumps oxlint 1.59.0 -> 1.69.0, oxlint-tsgolint 0.20.0 -> 0.23.0,
oxfmt 0.44.0 -> 0.54.0, and eslint-plugin-oxlint 1.59.0 -> 1.69.0.

oxlint 1.69 includes the Windows VirtualAlloc fix (oxc #22124) for the
fixed-size allocator, which previously committed ~130 GB per process and
could exhaust the system commit limit, causing intermittent
"Insufficient memory to create fixed-size allocator pool" panics.

format:check reports no reformatting and lint passes (0 errors).

Also adds --no-error-on-unmatched-pattern to the lint-staged oxlint
command: oxlint 1.69 now exits non-zero when no lintable files match
(e.g. a yaml/lock-only commit), which broke the pre-commit hook.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 22:50:22 -07:00
DrJKL
49026ebc90 refactor: move widget layout geometry into a frame-stable cache
Move BaseWidget's transient y/last_y/computedHeight off the instance into a
non-reactive WeakMap-backed layout cache (getWidgetLayout). The instance now
exposes them via accessors so canvas arrange/draw/hit-test code is unchanged
and the `in` operator still reports the props for extension compatibility.
This pulls per-frame render geometry out of the widget class, moving widgets
closer to plain data whose state lives in a queryable cache.
2026-06-13 21:35:24 -07:00
DrJKL
654ab9f189 refactor: move widget hidden/advanced flags into the value store
Delegate BaseWidget.hidden/advanced to the store-backed WidgetState via
accessors (mirrors value/label/disabled), so the flags are queryable by
WidgetId without an instance. Behavior-preserving.

Part of Phase 0 of the widget ECS decoupling (see temp/plans).

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 21:08:36 -07:00
DrJKL
1a1955a359 refactor: simplify store-backed widget control API
- Collapse setControlMode/setControlFilter/setControlExecuted into one
  updateWidgetControl(targetId, patch) store action
- Remove dead after-mode hasExecuted write in runWidgetControl
- Un-export internal-only WidgetControlPhase and ValueControlTarget
- Replace 'as never' cast with 'as ValueControlMode'

Amp-Thread-ID: https://ampcode.com/threads/T-019ec073-d520-774a-9529-5b6b932336e8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 03:30:50 -07:00
DrJKL
284a47261a refactor: consolidate widget control logic into one module
Collapse the distributed control lifecycle under
src/core/graph/widgets/control/: merge registration and positional
(de)serialization into widgetControl.ts, and move widgetControlSystem.ts
into the same folder. valueControl.ts stays a pure leaf (widgetState.ts
imports its type) and runWidgetControl stays separate so litegraph
serialization does not transitively import settingStore.

Reuse the canonical applyControlValues bridge in widgetInputs.ts,
replacing two hand-rolled positional [mode, filter] decode blocks so the
layout is decoded in one place instead of three.

Amp-Thread-ID: https://ampcode.com/threads/T-019ec02d-e454-72b9-bf54-3c2912898cc8
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 03:01:24 -07:00
DrJKL
cb0b7da933 fix: preserve interior promoted widget value when host slot is a null hole
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-13 00:26:24 -07:00
DrJKL
a6c54c9d8a feat: project widget control rows onto the classic canvas
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 22:31:34 -07:00
DrJKL
a80bf7dadb test: cover legacy positional widget control load compatibility
Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 22:30:57 -07:00
DrJKL
d62da6ec14 refactor: model widget control as a component, not a widget
Widget control state (mode, filter, hasExecuted) is now an ECS component
keyed by the target widget's WidgetId in widgetValueStore, replacing the
control/filter widget entities and the IS_CONTROL_WIDGET marker.

- Defer control registration like value registration: widgets carry a
  transient controlConfig, registered in BaseWidget.setNodeId.
- Preserve the positional [target, mode, filter?] widgets_values layout via
  serialize/configure interleaving (no control widgets resurrected).
- Promotion copies the interior control component once onto the host target,
  then diverges independently; no promoted/non-promoted distinction.
- Remove controlWidgetMarker, syncWidgetControl, the GraphCanvas control-label
  sweep, and control-widget creation in addValueControlWidget(s).

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 19:50:23 -07:00
DrJKL
6cd46cb1ce refactor: treat promoted control widgets as ordinary host widgets
Remove the separate promoted-control registration and persistence paths.
A promoted controllable widget now mints host-local control/filter widgets
that are plain store-backed host widgets surfaced by SubgraphNode.widgets in
order [target, control, filter], registered through the same
syncWidgetControl used by every node and persisted through the same
widgets_values array (controls have widget.serialize default).

configure() restores in one ordered pass with legacy detection for older
target-only widgets_values, preserving the quarantine override. Deletes
promoteWidgetControl and the properties.promotedControls schema/serialize/
restore special-casing.

linkedWidgets remains only a construction-time discovery detail.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 16:10:53 -07:00
DrJKL
fd26f61d8c refactor: independent store-backed control for promoted widgets
Render widget control from the store WidgetControlState keyed by the
widget's own widgetId instead of scanning linkedWidgets, so regular and
promoted widgets resolve control uniformly without tracing into a
subgraph interior.

On promotion, mint an independent host-local control (and filter) entity
seeded once from the interior widget, then queried/advanced purely by its
own ids. Persist the host control mode via a dedicated
properties.promotedControls entry, serialized and restored on configure.

linkedWidgets remains only as a construction-time discovery detail; its
non-control uses (bounding-box subwidgets, widget<->input conversion,
group nodes) are untouched.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 15:34:00 -07:00
DrJKL
579f2eab04 refactor: register widget control at value registration
Replace the ~180-line widget-control hooks installer with control
registration that piggybacks on widget value registration.

- Register control in BaseWidget.setNodeId and SubgraphNode._setWidget
  (thread interior node), deriving control/filter ids from nodeId + name
- Resolve link-fed suppression live in runWidgetControl via one graph pass
- Delete useWidgetControlHooks + GraphCanvas wiring, setInputLinked, and
  WidgetState.inputLinked
- Move isValueControlWidget into the controlWidgetMarker leaf module

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 11:41:41 -07:00
DrJKL
daf2a583bf refactor: store-backed widget control system
Replace per-widget control closures with an ECS-style component in the
widget value store. Control state is keyed by WidgetId, registration is
deferred like value registration, and values advance via a System scoped
to the queued graph.

- Add WidgetControlState component + store registration/scoping
- Add runWidgetControl System (graph-scoped, before/after phases)
- Sync controls + link state via installWidgetControlHooks
- Move control modules under core/graph/widgets/control
- Remove promotedWidgetControl special-casing

Amp-Thread-ID: https://ampcode.com/threads/T-019eb9d7-e812-7259-99c1-5188944fae57
Co-authored-by: Amp <amp@ampcode.com>
2026-06-12 08:07:55 -07:00
Alexander Brown
a9bee7639b Merge branch 'main' into drjkl/no-promotions 2026-06-11 11:31:48 -07:00
DrJKL
e20ca37606 test: assert promoted textarea materializes once on convert-to-subgraph
Covers the fresh interactive-add path alongside the existing load and clone
coverage. The prior conversion test only checked internal promoted-widget
state; this asserts the user-visible textbox appears exactly once, catching
both a missing widget and the duplicate-DOM-widget leak.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 23:58:58 -07:00
DrJKL
9e84a3f357 fix: defer promoted DOM widget materialization until host node is settled
Creating the DOMWidgetImpl during clone/configure used a transient node id,
seeding wrong nested values and leaking duplicate DOM widgets. Guard on graph
membership so the real widget is built on the post-settle resolution pass.

Rewrite subgraph tests to assert user-visible textboxes instead of internal
graph state.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 23:13:16 -07:00
DrJKL
612b8a4e92 refactor: treat promoted subgraph DOM widgets as normal DOM widgets
Remove promoted-widget special-casing so a promoted `customtext` textarea
on a SubgraphNode is built from the same shared multiline helpers as a
normal node widget, instead of teleport/position-override manipulation.

- Drop the position-override teleport path from the DOM widget store,
  DomWidgets.vue, and DomWidget.vue
- Extract shared textarea element + behavior wiring into
  multilineTextarea.ts, reused by useStringWidget and promotion
- Replace the dependency-injection factory registry with a protected
  createPromotedHostWidget hook on SubgraphNode, overridden by the
  app-layer ComfyNode subclass (avoids the litegraph barrel import cycle
  without a global registry)
- Materialize promoted DOM widgets via createPromotedMultilineWidget,
  store-backed by widgetId and registered directly with the DOM widget
  store (SubgraphNode.widgets is a projecting getter, so addDOMWidget
  cannot be used)

Amp-Thread-ID: https://ampcode.com/threads/T-019eb3aa-6bcd-7528-857c-1ff02c329af3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 20:43:41 -07:00
DrJKL
465e4fc9ec fix: bridge canvas edits of promoted subgraph widgets to the store
Promoted host widgets are plain store-backed POJOs. On the litegraph
canvas, edits go through toConcreteWidget, which wraps the POJO in a
transient BaseWidget whose setValue writes only its own local state and
never invokes the POJO value setter, so store.setValue was never called
and the widget appeared frozen.

Add a callback to both projection sites (_projectPromotedWidget and
promotedInputWidget) that writes through to the store. BaseWidget.setValue
calls this.callback, and callback survives the constructor Object.assign,
so canvas edits now reach the host store entry.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb367-9558-7415-a41b-8fe9a7181d1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 15:10:05 -07:00
DrJKL
1aa271538d test: assert cloned subgraph node keeps edited promoted widget values
Amp-Thread-ID: https://ampcode.com/threads/T-019eb320-d163-74ae-8367-46caf297a7ed
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 14:17:01 -07:00
DrJKL
e56b770d77 fix: tie promoted widget control execution flag to widget instance
Use a WeakSet keyed by the host widget instead of a module-level Set of
widget IDs so entries are released when the graph is cleared and widgets
are recreated, avoiding stale state across workflow reloads.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb305-7f1f-77c9-b3fb-b4ade5643bac
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 12:45:11 -07:00
DrJKL
73ec166f36 fix: port promoted combo host option sync to store-backed architecture
The merge with main brought in #12692's `syncPromotedComboHostOptions`,
which refreshed promoted combo host options via the deleted `world` ECS
layer (`@/world/widgetValueIO`, `isPromotedWidgetView`, `widget.entityId`).
This branch owns promoted widget values in `useWidgetValueStore`, so that
code failed typecheck.

Resolve the host options from the concrete interior widget via
`promotedInputSource` + `resolveConcretePromotedWidget` and write the
refreshed options into the store-backed snapshot. Rewrite the reloadNodeDefs
test to build a real subgraph host with a promoted combo input instead of
the removed world entity API.

Amp-Thread-ID: https://ampcode.com/threads/T-019eb2b6-3d9b-77cc-8e41-05cfcf7e788e
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 12:22:19 -07:00
DrJKL
c13b497f8d Merge remote-tracking branch 'origin/main' into drjkl/no-promotions 2026-06-10 11:38:36 -07:00
DrJKL
f020b4651f fix: apply control_after_generate to promoted subgraph widgets
Promoted subgraph widget values are owned by the host SubgraphNode in
useWidgetValueStore, while control_after_generate only mutated the
interior (now link-fed, dead) widget value. The displayed seed and the
queued prompt therefore never changed.

Apply control at the host level: resolve the interior control metadata
and write the next value to the host store-backed widget. Skips inputs
with an incoming link so externally-fed and nested intermediate hosts
are left untouched.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafb9-a9cb-71f3-85c8-590613527b86
Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 00:40:14 -07:00
GitHub Action
7196d92049 [automated] Apply ESLint and Oxfmt fixes 2026-06-10 03:59:22 +00:00
AustinMroz
a1ff977051 Add failing seed control tests (#12749)
This is a PR with intentionally failing tests targeting the branch for
#12617

This adds 4 failing seed control tests. Several of which are unfair and
will likely be discarded
- A simple test where a subgraph is converted and the value for
`control_after_generate` is changed
- A once functional workflow where proxyWidgets were used to control the
seed
- A never functional historic workflow where a link is made to a seed
- A never functional historic workflow where a link is made to a seed
and the original control_after_generate is promoted by proxyWidget
2026-06-09 20:55:40 -07:00
DrJKL
a896db128f fix: remove invalid second type arg from fromPartial call
Amp-Thread-ID: https://ampcode.com/threads/T-019eaf98-97c4-758f-870b-1884668ed7bc
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 20:52:26 -07:00
Alexander Brown
e012aee4e2 Apply suggestion from @AustinMroz
Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-09 19:45:07 -07:00
Alexander Brown
931a102cab Apply suggestion from @DrJKL 2026-06-09 19:30:37 -07:00
Alexander Brown
de96911a09 Apply suggestion from @AustinMroz
Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-09 19:29:42 -07:00
DrJKL
fc7f88fd9e refactor: import widget state directly
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:54:02 -07:00
DrJKL
2e36f8edf1 refactor: remove entity id shim
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:53:59 -07:00
Alexander Brown
d7f0e74e6a Merge branch 'main' into drjkl/no-promotions 2026-06-09 15:34:29 -07:00
DrJKL
bea38f4ad9 refactor: remove widget id shim
Amp-Thread-ID: https://ampcode.com/threads/T-019eae6e-d7a2-70d5-8053-819cfd8b641a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 15:22:39 -07:00
DrJKL
d9c5bd3300 fix: validate widget ids with regex
Amp-Thread-ID: https://ampcode.com/threads/T-019eae3f-f902-741a-91f2-e84d6436d6de
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 14:29:53 -07:00
DrJKL
0af90e77e5 test: clarify legacy input migration
Amp-Thread-ID: https://ampcode.com/threads/T-019eae3a-63b6-73d1-8a57-bbed83f1d63f
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 14:15:08 -07:00
DrJKL
2c73a29e47 fix: address widget promotion review
Amp-Thread-ID: https://ampcode.com/threads/T-019eaa30-b653-7781-8d6e-a93d6560f805
Co-authored-by: Amp <amp@ampcode.com>
2026-06-09 00:21:45 -07:00
DrJKL
4fac859988 test: align promoted widget reorder checks
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 18:04:11 -07:00
DrJKL
4fb0eba5f0 fix: isolate unknown widget fallback values
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 18:00:14 -07:00
DrJKL
166f24d462 fix: clear promoted widget errors by source
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 17:37:10 -07:00
DrJKL
817cee1806 fix: preserve promoted widget labels
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 17:25:45 -07:00
DrJKL
fbc5d96a5c fix: refresh subgraph panel after promotion
Amp-Thread-ID: https://ampcode.com/threads/T-019ea99f-4bae-776f-aabd-df47b0080719
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 16:56:20 -07:00
DrJKL
56225c8593 fix: resolve duplicate nested proxy widgets
Amp-Thread-ID: https://ampcode.com/threads/T-019ea986-7a67-732f-9714-5b2dd705ba54
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 16:36:33 -07:00
DrJKL
e471178861 Merge remote-tracking branch 'origin/main' into drjkl/no-promotions
# Conflicts:
#	src/composables/graph/useErrorClearingHooks.test.ts
#	src/composables/graph/useErrorClearingHooks.ts
2026-06-08 15:57:18 -07:00
DrJKL
4d020233bf refactor: store promoted widget layout
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 17:48:57 -07:00
DrJKL
6cbef8c22d fix: bind promoted LiteGraph widgets
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 17:23:10 -07:00
DrJKL
5e0f0cfdf9 fix: restore promoted widget resolution
Amp-Thread-ID: https://ampcode.com/threads/T-019ea366-1ffc-75da-9467-3da7c4567614
Co-authored-by: Amp <amp@ampcode.com>
2026-06-07 16:09:24 -07:00
DrJKL
eac6587bc4 test: read promoted widgets from store-backed inputs in e2e helper
getPromotedWidgets inspected widget.sourceNodeId/sourceWidgetName on
node.widgets, a denormalization the store-backed WidgetId refactor
removed, so it returned empty for every promoted widget. Resolve
promoted widgets from node.inputs by widgetId and walk the subgraph
input link for source identity, mirroring promotedInputWidgets and
resolveSubgraphInputTarget. Public helper API is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:40:40 -07:00
DrJKL
aa4b6c70c5 refactor: migrate proxy widget migration onto the shared projection
resolveSourceWidget builds the nested-subgraph source widget via
promotedInputWidget instead of a hand-rolled `as IBaseWidget` literal
carrying source fields. Consolidate the two bespoke promoted-widget test
helpers in promotionUtils.test into one thin wrapper over the real
projection, dropping the synthetic source-field shapes. PromotedWidgetSource
is now confined to the migration/schema layer where promotion sources are
persisted tuples rather than link-derivable.
2026-06-03 00:01:18 -07:00
DrJKL
93fdbab098 refactor: resolve promoted widget identity on demand, drop isPromotedWidget
Replace the isPromotedWidget duck-type and the source fields read off
widget objects with widgetPromotedSource(node, widget) in WidgetActions
(isLinked/handleHideInput) and SectionWidgets (isWidgetShownOnParents).
getInputSpecForWidget now resolves the real interior node-definition spec
for a subgraph node instead of fabricating one from store options, and
renameWidget collapses to a widgetId-keyed label-only write that never
touches name/widgetId. Delete the now-unused PromotedWidget type and
isPromotedWidget guard.
2026-06-02 23:41:53 -07:00
DrJKL
3471de82fb refactor: build subgraph parameter rows from the shared projection
Replace the hand-rolled promoted-widget builders in TabSubgraphInputs
(buildPromotedWidget/getPromotedWidgets) and SubgraphEditor
(buildPromotedDescriptors + PromotedDescriptor) with promotedInputWidgets
/promotedInputWidget. SubgraphEditor rows now carry the projected widget
plus the host input slot and resolve their interior source on demand for
demote/re-promote, so no source identity is denormalized onto the rows.
2026-06-02 23:23:33 -07:00
DrJKL
e797fc6c3a refactor: project promoted subgraph inputs through one module
Add src/core/graph/subgraph/promotedInputWidget.ts as the single
projection from a subgraph input slot to an ordinary store-backed
widget descriptor, and route useGraphNodeManager through it
(promotedInputWidgets + the shared safeWidgetMapper), deleting the
hand-rolled promotedInputToSafeWidget.

A projected widget name is now the input slot name (stable, unique,
the widgetId derives from it); the interior source widget name is
carried separately on SafeWidgetData (sourceWidgetName) only for
missing-model lookups, which key by the interior name. Drop the
redundant slotName and promotedLabel fields.
2026-06-02 23:14:03 -07:00
DrJKL
8623384c79 fix: keep promoted parameter rows stable across value edits
buildPromotedWidget read the store value at build time, so the row-list computed
reactively rebuilt on every edit, producing new descriptor objects and churning
getStableWidgetRenderKey — re-rendering the whole panel. Read type/value/options
through live getters instead so identity stays stable and value updates in place.
2026-06-02 21:05:58 -07:00
DrJKL
d01424f99e refactor: rename PromotedWidgetView type to PromotedWidget
The promoted host widget is no longer a synthetic view; reduce the type to the
promoted-source shape and rename the structural guard to isPromotedWidget.
2026-06-02 19:55:21 -07:00
DrJKL
0b99394a34 chore: remove widgetNodeTypeGuard, orphaned by the widget view deletion 2026-06-02 19:52:07 -07:00
DrJKL
2a4102690d feat: show subgraph node promoted widgets on the parameters tab
Build store-backed parameter rows for a subgraph node's promoted inputs from
node.inputs, unified with ordinary node widgets: display resolves through the
host widgetId and value writes go through widgetValueStore. Route the panel's
value write through widgetId so promoted inputs persist.
2026-06-02 19:47:35 -07:00
DrJKL
574c8d37d9 refactor: delete PromotedWidgetView class and collapse dead view guards
Remove the synthetic promoted-widget view class and PromotedWidgetViewManager.
Promoted host widgets are fully store-backed by widgetId, so the now-dead
isPromotedWidgetView branches over node.widgets/input._widget collapse to the
plain path. SubgraphNode loses its view machinery; getSlotFromWidget matches by
widgetId. Resolve the legacy selectedInput migration via resolveSubgraphInputTarget.

The structural isPromotedWidgetView guard and PromotedWidgetView type remain in
promotedWidgetTypes for the right-panel parameter components, pending their
store-driven rewrite.
2026-06-02 19:36:53 -07:00
DrJKL
7809c2faf8 feat: resolve nested promoted subgraph widgets without a widget view
Make resolveConcretePromotedWidget descend through subgraph inputs instead of
following synthetic widget views, so two-layer promotions resolve to the
deepest concrete widget. SubgraphNode assigns a widgetId to inputs whose source
is itself a promoted subgraph input, and dispatches the concrete interior
widget (not a view) on widget-promoted.
2026-06-02 18:51:24 -07:00
DrJKL
3e462b663a refactor: resolve nested proxy source via plain ref, not a widget view
The nested-subgraph branch of resolveSourceWidget now returns a plain source
ref (name/type/options/value/widgetId) instead of a PromotedWidgetView;
getSlotFromWidget still locates the backing slot by widgetId.
2026-06-02 18:40:54 -07:00
DrJKL
54a3b035b8 refactor: derive subgraph editor promoted rows from a plain descriptor
Replace the per-input PromotedWidgetView with a source descriptor built from
node inputs, and demote linked promotions by source via demotePromotedInput.
2026-06-02 18:35:36 -07:00
DrJKL
2082f5f43b refactor: build promoted subgraph widgets without a widget view
Map promoted subgraph inputs to SafeWidgetData directly from input.widgetId,
the resolved interior source widget, and widget state, instead of constructing
an ephemeral PromotedWidgetView per input.
2026-06-02 18:27:20 -07:00
DrJKL
c69f009775 chore: remove unused exports
Amp-Thread-ID: https://ampcode.com/threads/T-019e8af0-a3a2-7713-ba7e-a5579938f388
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 17:50:46 -07:00
DrJKL
749425d1ea test: update useGraphNodeManager tests to widget-id model
Remove the obsolete test that drove mapping through a persistent promoted view
on node.widgets (now empty; duplicate-named distinct identity is covered
elsewhere). Skip the two-layer nested promotion test as a documented known
limitation pending multi-level resolution support.
2026-06-02 17:41:14 -07:00
DrJKL
2157901a74 test: update useGLSLPreview getWidget mock to single widgetId arg
The impl now calls getWidget(widgetId(graphId, nodeId, name)); the test mock
still used the old three-arg signature and looked up by the wrong key. Extract
the widget name from the widget id.
2026-06-02 17:41:13 -07:00
DrJKL
b78de7c41f test: update litegraph-core tests to the empty-widgets model
SubgraphNode.widgets is now always empty (host widgets are store-backed), and
widget state nodeId is the string parsed from the widget id. Fix the BaseWidget
nodeId assertion, drop the stale subgraphNode.widgets assertion in SubgraphIO
(inputs already cover it), and remove the obsolete manual-widget-injection test.
2026-06-02 17:41:12 -07:00
DrJKL
3e8b0f56e9 fix: break litegraph init cycle via late-bound LiteGraph holder
draw.ts and BaseWidget.ts imported the LiteGraph singleton from the barrel,
which re-entered the barrel during BaseWidget initialization and evaluated
LegacyWidget extends BaseWidget before BaseWidget was defined (crashing any
entry that loaded a widget before the barrel, e.g. useGraphNodeManager tests).
Add a leaf litegraphInstance holder (type-only import) read at runtime via
litegraph(); the barrel constructs and registers the instance.
2026-06-02 17:41:11 -07:00
DrJKL
386e2d2796 refactor: derive subgraph editor promoted rows from inputs
The panel read promoted views off node.widgets, which is now empty. Build the
synth views from subgraph inputs carrying a widgetId (mirroring
useGraphNodeManager) so promoted rows, reorder, and demote work against
store-backed host widgets. Update tests to assert on promoted inputs.
2026-06-02 17:41:10 -07:00
DrJKL
914ceae014 refactor: derive subgraph widget input spec from widget state
getInputSpecForWidget resolved promoted views to read the source widget's
options. With host widgets store-backed, build the input spec from the widget's
widgetId state options (default, etc.) instead of following a view.
2026-06-02 17:41:10 -07:00
DrJKL
c87de750ca refactor: drop dead promoted-view branch from error clearing hooks
Promoted host widgets no longer surface as views on the subgraph node, so
onWidgetChanged never receives one. Remove resolvePromotedExecId and clear
errors using the node's own execution id. Drop the obsolete test that drove
clearing through a host-side promoted view.
2026-06-02 17:41:09 -07:00
DrJKL
c0fc78554a test: build promoted view directly in host-wins tests
SubgraphNode.widgets is empty, so the host-wins semantics tests can no longer
find the view there. Construct it via createPromotedWidgetView, mirroring how
useGraphNodeManager builds the synth view.
2026-06-02 17:41:08 -07:00
DrJKL
1f50a78530 refactor: finish entityId to widgetId field rename
The type rename WidgetEntityId->WidgetId left the widget field still named
entityId. Drop the entityId aliases on IBaseWidget/BaseWidget/PromotedWidgetView
and SafeWidgetData, and read widget.widgetId everywhere. Source widget name from
widget state when present so promoted host widgets render their canonical
identity instead of stale data.
2026-06-02 17:41:05 -07:00
DrJKL
5098fb8fa7 refactor: decouple widget render key and host-widget resolver from PromotedWidgetView
The render-key prefix was cosmetic and the host-widget resolver's promoted-chain
following was unreachable (subgraph host widgets never populate node.widgets).
Reduce both to view-free lookups and drop the obsolete chain-following tests.
2026-06-02 17:41:04 -07:00
DrJKL
c0b1ed9770 refactor: make widgetValueStore WidgetId-native and migrate subgraph host widgets
Rename WidgetEntityId to WidgetId throughout, delete widgetValueIO in favor
of a WidgetId-native widgetValueStore, and represent subgraph promoted host
widgets as store-backed widget state addressed by input.widgetId. Migrate
runtime consumers (graph node manager, processed widgets, right-side panel,
price/error utilities) toward widget-id state.
2026-06-02 17:40:58 -07:00
145 changed files with 6612 additions and 5947 deletions

View File

@@ -0,0 +1,436 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["-1", "seed"]]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -0,0 +1,404 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 14,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["3", "seed"]]
},
"widgets_values": []
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -0,0 +1,439 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [498.26665242513025, 471.46666463216144],
"size": [510.328125, 252.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [500.66667683919275, 227.8666280110677],
"size": [507.40625, 233.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [570.266591389974, 735.4665120442708],
"size": [378, 216],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1453.466512044271, 230.26666768391925],
"size": [252, 138],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.866658528646, 231.46666463216144],
"size": [252, 148],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.866689046223996, 573.4666951497395],
"size": [378, 196],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.9444173177085, 343.52284749348956],
"size": [225, 220],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [
["-1", "seed"],
["3", "control_after_generate"]
]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.35.0"
},
"version": 0.4
}

View File

@@ -1,6 +1,5 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -8,8 +7,13 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
interface ResolvedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
function widgetSourceToEntry(
source: PromotedWidgetSource
source: ResolvedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
@@ -20,23 +24,22 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
/**
* Reads the promoted widgets of a subgraph host node from the live graph.
*
* Promoted widgets are now store-backed: a host input is promoted iff it
* carries a `widgetId`, and its interior source identity is resolved on demand
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
* denormalization, so the helper reflects the real projection rather than a
* deleted widget-object contract.
*/
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
@@ -44,21 +47,49 @@ export async function getPromotedWidgets(
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
const previewExposures = node?.serialize()?.properties?.previewExposures
if (!node?.isSubgraphNode?.())
return { widgetSources: [], previewExposures }
const { subgraph } = node
const resolveSource = (
inputName: string
): ResolvedWidgetSource | undefined => {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
for (const linkId of inputSlot.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
if (inputNode.isSubgraphNode?.()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: widget.name
}
}
return undefined
}
const widgetSources = (node.inputs ?? []).flatMap((input) => {
if (!input.widgetId) return []
const source = resolveSource(input.name)
return source ? [source] : []
})
return { widgetSources, previewExposures }
},
nodeId
)
@@ -67,7 +98,7 @@ export async function getPromotedWidgets(
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...widgetSources.map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}

View File

@@ -217,6 +217,14 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
}
})
// Each promoted input must surface its own source value, so assert the
// name->value mapping rather than the first textbox in DOM order.
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
value: /Inner 1/,
value_1: /Inner 2/,
value_1_1: /Inner 3/
}
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
comfyPage
}) => {
@@ -228,11 +236,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgets).toHaveCount(4)
const valueWidget = outerNode
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNode.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
})
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
@@ -271,11 +284,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
const valueWidget = outerNodeAfter
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNodeAfter.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
})
}
)

View File

@@ -53,6 +53,22 @@ test.describe(
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator(String(subgraphNode.id))
.getByRole('textbox', { name: 'text', exact: true })
await expect(promotedTextarea).toHaveCount(1)
await expect(promotedTextarea).toBeVisible()
})
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },

View File

@@ -0,0 +1,50 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
wstest(
'Seed handling',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
async function verifySeedControl(initializeState = true) {
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
const { input, valueControl } =
comfyPage.vueNodes.getInputNumberControls(seedWidget)
if (initializeState) {
await input.fill('1')
await valueControl.click()
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
await comfyPage.keyboard.press('Escape')
}
await execution.run()
await expect.soft(input).toHaveValue('2')
}
await test.step('seed updates on generation', async () => {
await verifySeedControl()
})
await test.step('subgraph seed updates on generation', async () => {
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await verifySeedControl()
})
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
await test.step(`seed updates for old workflow: ${w}`, async () => {
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
await verifySeedControl(false)
})
}
}
)

View File

@@ -484,6 +484,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
'subgraphs/subgraph-with-promoted-text-widget'
)
// Assert against the visible textbox the user sees, not the internal
// graph/widget projection.
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
name: 'text',
exact: true
})
await comfyExpect(promotedTextWidgets).toHaveCount(1)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
@@ -497,31 +505,58 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.page.keyboard.up('Alt')
}
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
await comfyExpect(promotedTextWidgets).toHaveCount(2)
})
test(
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const editedValue = 'Edited prompt that must survive cloning'
const originalTextbox = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(originalTextbox).toBeVisible()
await expect(originalTextbox).toHaveValue('')
await originalTextbox.fill(editedValue)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
await originalNode.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const textbox = comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
await expect(
textbox,
`node ${nodeId} promoted text widget reset to default after clone`
).toHaveValue(editedValue)
}
}
)
})
test.describe('Duplicate ID Remapping', () => {

View File

@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

443
pnpm-lock.yaml generated
View File

@@ -211,8 +211,8 @@ catalogs:
specifier: ^4.16.2
version: 4.16.2
eslint-plugin-oxlint:
specifier: 1.59.0
version: 1.59.0
specifier: 1.69.0
version: 1.69.0
eslint-plugin-playwright:
specifier: ^2.10.1
version: 2.10.1
@@ -277,14 +277,14 @@ catalogs:
specifier: ^2.12.9
version: 2.12.9
oxfmt:
specifier: ^0.44.0
version: 0.44.0
specifier: ^0.54.0
version: 0.54.0
oxlint:
specifier: ^1.59.0
version: 1.59.0
specifier: ^1.69.0
version: 1.69.0
oxlint-tsgolint:
specifier: ^0.20.0
version: 0.20.0
specifier: ^0.23.0
version: 0.23.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -717,13 +717,13 @@ importers:
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1)))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3)
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
version: 1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0))
eslint-plugin-playwright:
specifier: 'catalog:'
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
@@ -777,13 +777,13 @@ importers:
version: 2.12.9
oxfmt:
specifier: 'catalog:'
version: 0.44.0
version: 0.54.0
oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint-tsgolint@0.20.0)
version: 1.69.0(oxlint-tsgolint@0.23.0)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.20.0
version: 0.23.0
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2751,276 +2751,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.44.0':
resolution: {integrity: sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==}
'@oxfmt/binding-android-arm-eabi@0.54.0':
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.44.0':
resolution: {integrity: sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==}
'@oxfmt/binding-android-arm64@0.54.0':
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.44.0':
resolution: {integrity: sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==}
'@oxfmt/binding-darwin-arm64@0.54.0':
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.44.0':
resolution: {integrity: sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==}
'@oxfmt/binding-darwin-x64@0.54.0':
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.44.0':
resolution: {integrity: sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==}
'@oxfmt/binding-freebsd-x64@0.54.0':
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
resolution: {integrity: sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==}
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
resolution: {integrity: sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==}
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
resolution: {integrity: sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==}
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.44.0':
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
'@oxfmt/binding-linux-arm64-musl@0.54.0':
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.44.0':
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
'@oxfmt/binding-linux-x64-gnu@0.54.0':
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.44.0':
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
'@oxfmt/binding-linux-x64-musl@0.54.0':
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.44.0':
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
'@oxfmt/binding-openharmony-arm64@0.54.0':
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
resolution: {integrity: sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==}
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
resolution: {integrity: sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==}
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.44.0':
resolution: {integrity: sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==}
'@oxfmt/binding-win32-x64-msvc@0.54.0':
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.20.0':
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
'@oxlint-tsgolint/darwin-arm64@0.23.0':
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.20.0':
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
'@oxlint-tsgolint/darwin-x64@0.23.0':
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.20.0':
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
'@oxlint-tsgolint/linux-arm64@0.23.0':
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.20.0':
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
'@oxlint-tsgolint/linux-x64@0.23.0':
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.20.0':
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
'@oxlint-tsgolint/win32-arm64@0.23.0':
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.20.0':
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
'@oxlint-tsgolint/win32-x64@0.23.0':
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.59.0':
resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==}
'@oxlint/binding-android-arm-eabi@1.69.0':
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.59.0':
resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==}
'@oxlint/binding-android-arm64@1.69.0':
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.59.0':
resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==}
'@oxlint/binding-darwin-arm64@1.69.0':
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.59.0':
resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==}
'@oxlint/binding-darwin-x64@1.69.0':
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.59.0':
resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==}
'@oxlint/binding-freebsd-x64@1.69.0':
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==}
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==}
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.59.0':
resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==}
'@oxlint/binding-linux-arm64-gnu@1.69.0':
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.59.0':
resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==}
'@oxlint/binding-linux-arm64-musl@1.69.0':
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==}
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==}
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.59.0':
resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==}
'@oxlint/binding-linux-riscv64-musl@1.69.0':
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.59.0':
resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==}
'@oxlint/binding-linux-s390x-gnu@1.69.0':
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.59.0':
resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==}
'@oxlint/binding-linux-x64-gnu@1.69.0':
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.59.0':
resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==}
'@oxlint/binding-linux-x64-musl@1.69.0':
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.59.0':
resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==}
'@oxlint/binding-openharmony-arm64@1.69.0':
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.59.0':
resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==}
'@oxlint/binding-win32-arm64-msvc@1.69.0':
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.59.0':
resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==}
'@oxlint/binding-win32-ia32-msvc@1.69.0':
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.59.0':
resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==}
'@oxlint/binding-win32-x64-msvc@1.69.0':
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -5395,10 +5395,10 @@ packages:
eslint-import-resolver-node:
optional: true
eslint-plugin-oxlint@1.59.0:
resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==}
eslint-plugin-oxlint@1.69.0:
resolution: {integrity: sha512-ryJT8Pqb3jgWhmQcKA/D98K6UckthAR70wPTBI4rOjcaKJ9nmQkysTLbTVVEcdzfT9mznV/2MKspBsCCpXm36w==}
peerDependencies:
oxlint: ~1.59.0
oxlint: ~1.69.0
eslint-plugin-playwright@2.10.1:
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
@@ -7017,24 +7017,35 @@ packages:
oxc-resolver@11.20.0:
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
oxfmt@0.44.0:
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.20.0:
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
hasBin: true
oxlint@1.59.0:
resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
svelte:
optional: true
vite-plus:
optional: true
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
oxlint@1.69.0:
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -8629,8 +8640,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.3:
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -10739,136 +10750,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.44.0':
'@oxfmt/binding-android-arm-eabi@0.54.0':
optional: true
'@oxfmt/binding-android-arm64@0.44.0':
'@oxfmt/binding-android-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.44.0':
'@oxfmt/binding-darwin-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-x64@0.44.0':
'@oxfmt/binding-darwin-x64@0.54.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.44.0':
'@oxfmt/binding-freebsd-x64@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.44.0':
'@oxfmt/binding-linux-arm64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.44.0':
'@oxfmt/binding-linux-x64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.44.0':
'@oxfmt/binding-linux-x64-musl@0.54.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.44.0':
'@oxfmt/binding-openharmony-arm64@0.54.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.44.0':
'@oxfmt/binding-win32-x64-msvc@0.54.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.20.0':
'@oxlint-tsgolint/darwin-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.20.0':
'@oxlint-tsgolint/darwin-x64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.20.0':
'@oxlint-tsgolint/linux-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.20.0':
'@oxlint-tsgolint/linux-x64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.20.0':
'@oxlint-tsgolint/win32-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.20.0':
'@oxlint-tsgolint/win32-x64@0.23.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.59.0':
'@oxlint/binding-android-arm-eabi@1.69.0':
optional: true
'@oxlint/binding-android-arm64@1.59.0':
'@oxlint/binding-android-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-arm64@1.59.0':
'@oxlint/binding-darwin-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-x64@1.59.0':
'@oxlint/binding-darwin-x64@1.69.0':
optional: true
'@oxlint/binding-freebsd-x64@1.59.0':
'@oxlint/binding-freebsd-x64@1.69.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.59.0':
'@oxlint/binding-linux-arm64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.59.0':
'@oxlint/binding-linux-arm64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.59.0':
'@oxlint/binding-linux-riscv64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.59.0':
'@oxlint/binding-linux-s390x-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.59.0':
'@oxlint/binding-linux-x64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.59.0':
'@oxlint/binding-linux-x64-musl@1.69.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.59.0':
'@oxlint/binding-openharmony-arm64@1.69.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.59.0':
'@oxlint/binding-win32-arm64-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.59.0':
'@oxlint/binding-win32-ia32-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.59.0':
'@oxlint/binding-win32-x64-msvc@1.69.0':
optional: true
'@package-json/types@0.0.12': {}
@@ -11312,7 +11323,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.3
vue-component-type-helpers: 3.3.4
'@swc/helpers@0.5.21':
dependencies:
@@ -13448,7 +13459,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -13461,7 +13472,7 @@ snapshots:
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 10.4.0(jiti@2.6.1)
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
transitivePeerDependencies:
- typescript
@@ -13483,10 +13494,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)):
eslint-plugin-oxlint@1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0)):
dependencies:
jsonc-parser: 3.3.1
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
eslint-plugin-playwright@2.10.1(eslint@10.4.0(jiti@2.6.1)):
dependencies:
@@ -15453,61 +15464,61 @@ snapshots:
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
oxfmt@0.44.0:
oxfmt@0.54.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.44.0
'@oxfmt/binding-android-arm64': 0.44.0
'@oxfmt/binding-darwin-arm64': 0.44.0
'@oxfmt/binding-darwin-x64': 0.44.0
'@oxfmt/binding-freebsd-x64': 0.44.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.44.0
'@oxfmt/binding-linux-arm-musleabihf': 0.44.0
'@oxfmt/binding-linux-arm64-gnu': 0.44.0
'@oxfmt/binding-linux-arm64-musl': 0.44.0
'@oxfmt/binding-linux-ppc64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-musl': 0.44.0
'@oxfmt/binding-linux-s390x-gnu': 0.44.0
'@oxfmt/binding-linux-x64-gnu': 0.44.0
'@oxfmt/binding-linux-x64-musl': 0.44.0
'@oxfmt/binding-openharmony-arm64': 0.44.0
'@oxfmt/binding-win32-arm64-msvc': 0.44.0
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
'@oxfmt/binding-win32-x64-msvc': 0.44.0
'@oxfmt/binding-android-arm-eabi': 0.54.0
'@oxfmt/binding-android-arm64': 0.54.0
'@oxfmt/binding-darwin-arm64': 0.54.0
'@oxfmt/binding-darwin-x64': 0.54.0
'@oxfmt/binding-freebsd-x64': 0.54.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
'@oxfmt/binding-linux-arm64-musl': 0.54.0
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
'@oxfmt/binding-linux-x64-gnu': 0.54.0
'@oxfmt/binding-linux-x64-musl': 0.54.0
'@oxfmt/binding-openharmony-arm64': 0.54.0
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
'@oxfmt/binding-win32-x64-msvc': 0.54.0
oxlint-tsgolint@0.20.0:
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.20.0
'@oxlint-tsgolint/darwin-x64': 0.20.0
'@oxlint-tsgolint/linux-arm64': 0.20.0
'@oxlint-tsgolint/linux-x64': 0.20.0
'@oxlint-tsgolint/win32-arm64': 0.20.0
'@oxlint-tsgolint/win32-x64': 0.20.0
'@oxlint-tsgolint/darwin-arm64': 0.23.0
'@oxlint-tsgolint/darwin-x64': 0.23.0
'@oxlint-tsgolint/linux-arm64': 0.23.0
'@oxlint-tsgolint/linux-x64': 0.23.0
'@oxlint-tsgolint/win32-arm64': 0.23.0
'@oxlint-tsgolint/win32-x64': 0.23.0
oxlint@1.59.0(oxlint-tsgolint@0.20.0):
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.59.0
'@oxlint/binding-android-arm64': 1.59.0
'@oxlint/binding-darwin-arm64': 1.59.0
'@oxlint/binding-darwin-x64': 1.59.0
'@oxlint/binding-freebsd-x64': 1.59.0
'@oxlint/binding-linux-arm-gnueabihf': 1.59.0
'@oxlint/binding-linux-arm-musleabihf': 1.59.0
'@oxlint/binding-linux-arm64-gnu': 1.59.0
'@oxlint/binding-linux-arm64-musl': 1.59.0
'@oxlint/binding-linux-ppc64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-musl': 1.59.0
'@oxlint/binding-linux-s390x-gnu': 1.59.0
'@oxlint/binding-linux-x64-gnu': 1.59.0
'@oxlint/binding-linux-x64-musl': 1.59.0
'@oxlint/binding-openharmony-arm64': 1.59.0
'@oxlint/binding-win32-arm64-msvc': 1.59.0
'@oxlint/binding-win32-ia32-msvc': 1.59.0
'@oxlint/binding-win32-x64-msvc': 1.59.0
oxlint-tsgolint: 0.20.0
'@oxlint/binding-android-arm-eabi': 1.69.0
'@oxlint/binding-android-arm64': 1.69.0
'@oxlint/binding-darwin-arm64': 1.69.0
'@oxlint/binding-darwin-x64': 1.69.0
'@oxlint/binding-freebsd-x64': 1.69.0
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
'@oxlint/binding-linux-arm64-gnu': 1.69.0
'@oxlint/binding-linux-arm64-musl': 1.69.0
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-musl': 1.69.0
'@oxlint/binding-linux-s390x-gnu': 1.69.0
'@oxlint/binding-linux-x64-gnu': 1.69.0
'@oxlint/binding-linux-x64-musl': 1.69.0
'@oxlint/binding-openharmony-arm64': 1.69.0
'@oxlint/binding-win32-arm64-msvc': 1.69.0
'@oxlint/binding-win32-ia32-msvc': 1.69.0
'@oxlint/binding-win32-x64-msvc': 1.69.0
oxlint-tsgolint: 0.23.0
p-limit@3.1.0:
dependencies:
@@ -17458,7 +17469,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.3: {}
vue-component-type-helpers@3.3.4: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -79,7 +79,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.59.0
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
@@ -101,9 +101,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
function removeSelectedWidgetId(widgetId: WidgetId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
const widgetId = widget.widgetId
if (!widgetId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
[entry.widgetId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
:remove="() => removeSelectedWidgetId(entry.widgetId)"
/>
</template>
</DraggableList>

View File

@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.entityId === entityId
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,

View File

@@ -1,12 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -22,18 +23,29 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs: [],
isSubgraphNode: () => false,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
}))
})
}
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs,
isSubgraphNode: () => true,
widgets: []
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
@@ -88,4 +100,27 @@ describe('useResolvedSelectedInputs', () => {
expect(resolved.value[0]?.status).toBe('unknown')
})
it('resolves promoted subgraph inputs from their host input widgetId', () => {
const node = makeSubgraphNode(1, [
fromPartial<INodeInputSlot>({
name: 'seed',
label: 'renamed_seed',
widgetId: entitySeed
})
])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]).toMatchObject({
status: 'resolved',
node,
displayName: 'seed',
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
})
})
})

View File

@@ -1,18 +1,19 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
widgetId: WidgetId
node: LGraphNode
widget: IBaseWidget
displayName: string
@@ -20,7 +21,7 @@ export type ResolvedSelection =
}
| {
status: 'unknown'
entityId: WidgetEntityId
widgetId: WidgetId
displayName: string
config?: InputWidgetConfig
}
@@ -54,16 +55,19 @@ export function useResolvedSelectedInputs() {
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
([widgetId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetId(widgetId)) return []
const { nodeId, name } = parseWidgetId(widgetId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
const widgets = node?.isSubgraphNode()
? promotedInputWidgets(node)
: node?.widgets
const widget = widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
return [{ status: 'unknown', widgetId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
{ status: 'resolved', widgetId, node, widget, displayName, config }
]
}
)

View File

@@ -57,154 +57,85 @@ function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets transition grace characterization', () => {
describe('DomWidgets positioning', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies transition grace for exactly one frame when override exists but is not active', () => {
it('positions an active visible widget relative to its owning node', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
const widget = createWidget('widget-transition', interiorNode, 14)
const overrideWidget = createWidget('override-widget', overrideNode, 22)
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [100, 200])
const widget = createWidget('widget-pos', node, 14)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([110, 224])
})
it('hides a widget whose owning node is in a different graph', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const currentGraph = new LGraph()
const otherGraph = new LGraph()
const node = createNode(otherGraph, 1, 'host', [100, 200])
const widget = createWidget('widget-other-graph', node, 14)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(currentGraph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(false)
})
it('hides an inactive widget', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [0, 0])
const widget = createWidget('widget-inactive', node, 10)
domWidgetStore.registerWidget(widget)
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
widgetState.visible = true
widgetState.pos = [321, 654]
const canvas = createCanvas(graphA)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([321, 654])
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
})
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
const widget = createWidget('widget-override-active', interiorNode, 8)
const overrideWidget = createWidget(
'override-position-source',
overrideNode,
18
)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([310, 428])
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
const overrideWidget = createWidget(
'shared-override-widget',
overrideNode,
14
)
domWidgetStore.registerWidget(oldWidget)
domWidgetStore.setPositionOverride(oldWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(oldWidget.id)
drawFrame(canvas)
domWidgetStore.unregisterWidget(oldWidget.id)
drawFrame(canvas)
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
domWidgetStore.registerWidget(replacementWidget)
domWidgetStore.setPositionOverride(replacementWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(replacementWidget.id)
const replacementState = domWidgetStore.widgetStates.get(
replacementWidget.id
)
if (!replacementState) throw new Error('Replacement widget missing state')
replacementState.visible = true
replacementState.pos = [999, 999]
drawFrame(canvas)
expect(replacementState.visible).toBe(true)
expect(replacementState.pos).toEqual([999, 999])
})
})

View File

@@ -21,7 +21,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
const domWidgetStore = useDomWidgetStore()
const overrideTransitionGrace = new Set<string>()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
@@ -31,47 +30,16 @@ const updateWidgets = () => {
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
const seenWidgetIds = new Set<string>()
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
seenWidgetIds.add(widget.id)
// Use position override only when the override node (SubgraphNode) is
// in the current graph. When the user enters the subgraph, the override
// node is no longer visible — fall back to the widget's own node.
// Use graph reference equality (IDs are not unique across graphs).
const override = widgetState.positionOverride
const useOverride = !!override && currentGraph === override.node.graph
const inOverrideTransitionGap =
!!override && !useOverride && !widgetState.active
const useTransitionGrace =
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
if (useTransitionGrace) {
overrideTransitionGrace.add(widget.id)
} else if (!inOverrideTransitionGap) {
overrideTransitionGrace.delete(widget.id)
}
// Early exit for non-visible widgets.
// When a position override is active (widget promoted to SubgraphNode),
// the interior widget's `active` flag is false (its node is in the
// subgraph, not the current graph) — bypass that check.
if (
!widget.isVisible() ||
(!widgetState.active && !useOverride && !useTransitionGrace)
) {
if (!widget.isVisible() || !widgetState.active) {
widgetState.visible = false
continue
}
// During graph transitions, hold the previous position for one frame
// so promoted widgets don't briefly disappear before activation flips.
if (useTransitionGrace) continue
const posNode = useOverride ? override.node : widget.node
const posWidget = useOverride ? override.widget : widget
const posNode = widget.node
const isInCorrectGraph = posNode.graph === currentGraph
const nodeVisible = lgCanvas.isNodeVisible(posNode)
@@ -85,22 +53,16 @@ const updateWidgets = () => {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + posWidget.y
posNode.pos[1] + margin + widget.y
]
widgetState.size = [
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
(widget.width ?? posNode.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
}
}
for (const widgetId of overrideTransitionGrace) {
if (!seenWidgetIds.has(widgetId)) {
overrideTransitionGrace.delete(widgetId)
}
}
}
const canvasStore = useCanvasStore()

View File

@@ -174,7 +174,6 @@ import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes
import { UnauthorizedError } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
@@ -189,7 +188,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
@@ -346,26 +344,6 @@ watchEffect(() => {
})
})
watch(
() => settingStore.get('Comfy.WidgetControlMode'),
() => {
if (!canvasStore.canvas) return
forEachNode(comfyApp.rootGraph, (n) => {
if (!n.widgets) return
for (const w of n.widgets) {
if (!w[IS_CONTROL_WIDGET]) continue
updateControlWidgetLabel(w)
if (!w.linkedWidgets) continue
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l)
}
}
})
canvasStore.canvas.setDirty(true)
}
)
watch(
[() => canvasStore.canvas, () => settingStore.get('Comfy.ColorPalette')],
async ([canvas, currentPaletteId]) => {

View File

@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
function createWidgetState(disabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
@@ -70,14 +70,10 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
value: '',
options: {},
node,
computedDisabled: false
computedDisabled: disabled
})
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
@@ -98,7 +94,7 @@ describe('DomWidget disabled style', () => {
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
it('uses disabled style when widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const { container } = render(DomWidget, {
props: {

View File

@@ -69,11 +69,7 @@ const updateDomClipping = () => {
return
}
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
const isSelected = selectedNode === ownerNode
const isSelected = selectedNode === widgetState.widget.node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -104,10 +100,7 @@ const updateDomClipping = () => {
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
function composeStyle() {
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
const isDisabled = widget.computedDisabled
style.value = {
...positionStyle.value,
@@ -167,13 +160,7 @@ onMounted(() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph
? override.node
: widgetState.widget.node
const ownerNode = widgetState.widget.node
lgCanvas.selectNode(ownerNode)
lgCanvas.bringToFront(ownerNode)
}

View File

@@ -12,14 +12,15 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -146,16 +147,17 @@ function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
if (source) {
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
? source.nodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: source.widgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
@@ -234,7 +236,10 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
@@ -245,18 +250,18 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
writeWidgetValue(widget, defaultValue)
setWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
defineExpose({

View File

@@ -0,0 +1,127 @@
import { render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
})
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
rows: []
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode
widget: IBaseWidget
}[]
return () => null
}
}
function buildHostWithPromotedSeed(): {
host: SubgraphNode
sourceNode: LGraphNode
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('Sampler')
const input = sourceNode.addInput('seed', 'INT')
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(sourceNode)
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
return { host, sourceNode }
}
function renderPanel(node: SubgraphNode) {
return render(TabSubgraphInputs, {
props: { node },
global: {
plugins: [i18n],
stubs: {
SectionWidgets: SectionWidgetsStub,
AsyncSearchInput: true,
CollapseToggleButton: true
}
}
})
}
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
captured.rows = []
vi.clearAllMocks()
})
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow).toBeDefined()
expect(seedRow?.node.id).toBe(host.id)
expect(seedRow?.widget.type).toBe('number')
expect(seedRow?.widget.widgetId).toBe(
widgetId(host.rootGraph.id, host.id, 'seed')
)
expect(seedRow?.widget.value).toBe(42)
})
it('reflects the current host widget value from the store', () => {
const { host } = buildHostWithPromotedSeed()
const id = widgetId(host.rootGraph.id, host.id, 'seed')
useWidgetValueStore().setValue(id, 7)
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow?.widget.value).toBe(7)
})
it('reflects value changes through the same descriptor without rebuilding it', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
expect(seedRow.widget.value).toBe(42)
// A value edit must not require a new descriptor object: the same row
// reflects the store change via its live getter, keeping render keys stable.
useWidgetValueStore().setValue(
widgetId(host.rootGraph.id, host.id, 'seed'),
100
)
expect(seedRow.widget.value).toBe(100)
})
})

View File

@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
})
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
async (section) => {
@@ -93,7 +66,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return getPromotedWidgets().map((widget) => ({ node, widget }))
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {

View File

@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -45,8 +47,10 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const currentValue = computed(
() =>
(widget.widgetId &&
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
widget.value
)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(widget.value, defaultValue.value)
return isEqual(currentValue.value, defaultValue.value)
})
async function handleRename() {
@@ -77,21 +88,15 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const source = widgetPromotedSource(node, widget)
if (source) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
vi.clearAllMocks()
})
describe('promoted widget options', () => {
describe('widget state rendering', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
})
})
it('passes options from a PromotedWidgetView to the widget component', () => {
it('passes options from widget state to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: expectedOptions
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -17,11 +16,12 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { renameWidget } from '@/utils/widgetUtil'
@@ -67,35 +67,32 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
const bareNodeId = stripGraphPrefix(String(node.id))
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
const widgetType = widgetState?.type ?? widget.type
return {
name: widget.name,
type: widget.type,
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
controlWidget: getControlWidget(widget)
}
})
const sourceNodeName = computed((): string | null => {
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const displayNodeName = computed((): string | null => {
if (!node) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
return resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -167,10 +164,10 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && sourceNodeName"
v-if="(showNodeName || hasParents) && displayNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ sourceNodeName }}
{{ displayNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"

View File

@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
const rowFor = (sourceNode: LGraphNode) => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
})!
return {
kind: 'promoted',
node: sourceNode,
input,
widget: promotedInputWidget(input)!
}
}
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
@@ -182,6 +192,42 @@ describe('SubgraphEditor', () => {
).toEqual(['second', 'first'])
})
it('moves a widget to shown when promoted from the hidden section', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const sourceNode = new LGraphNode('SourceNode')
subgraph.add(sourceNode)
const sourceInput = sourceNode.addInput('first', 'STRING')
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
sourceInput.widget = { name: sourceWidget.name }
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -213,13 +259,13 @@ describe('SubgraphEditor', () => {
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {

View File

@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
@@ -16,8 +15,14 @@ import {
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import {
promotedInputSource,
promotedInputWidget
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
input: INodeInputSlot
widget: IBaseWidget
}
type PreviewRow = {
kind: 'preview'
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
return undefined
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
const promotedRows = shallowRef<readonly PromotedRow[]>([])
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
return node.inputs.flatMap((input): PromotedRow[] => {
const widget = promotedInputWidget(input)
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
function refreshPromotedRows() {
const node = activeNode.value
promotedRows.value = node ? buildPromotedRows(node) : []
}
watch(activeNode, refreshPromotedRows, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
@@ -68,34 +86,29 @@ useEventListener(
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
refreshPromotedRows
)
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
const node = activeNode.value
return node ? promotedInputSource(node, row.input) : undefined
}
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
return [...promotedRows.value, ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
return [...promotedRows.value]
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
value.map((row) => ({ widgetId: row.widget.widgetId }))
)
}
refreshPromotedWidgetRendering()
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
if (row.kind !== 'promoted')
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
const source = promotedRowSource(row)
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
return `${row.node.id}: ${row.widget.name}`
}
function rowKey(row: ActiveRow): string {
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
}
refreshPromotedWidgetRendering()
return
}
if (row.realWidget) {
@@ -274,13 +294,18 @@ function demoteRow(row: ActiveRow) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
const sourceWidget = source
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
: undefined
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
refreshPromotedRows()
}
function showAll() {

View File

@@ -4,12 +4,8 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -256,273 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {
@@ -1249,4 +978,54 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
clearSpy.mockRestore()
})
it('clears promoted widget errors by interior execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: 2 })
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = 1
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(
'combo',
'ckpt_name',
'fake_model.safetensors',
() => undefined,
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
)
input.widget = { name: widget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
).toBe(true)
installErrorClearingHooks(graph)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: '2:1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
const promotedWidget = host.widgets[0]
host.onWidgetChanged!.call(
host,
promotedWidget.name,
'real_model.safetensors',
'fake_model.safetensors',
promotedWidget
)
expect(missingModelStore.hasMissingModels).toBe(false)
})
})

View File

@@ -6,12 +6,9 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
@@ -46,130 +43,6 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
}
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
@@ -203,21 +76,24 @@ function installNodeHooks(node: LGraphNode): void {
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId,
newValue
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
executionId,
widgetName,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

View File

@@ -1,13 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const id = widgetId(graph.id, node.id, 'testnum')
expect(store.getWidget(id)?.value).toBe(2)
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(id)
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
const interiorInput = interiorNode.addInput('value', 'STRING')
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
interiorInput.widget = { name: 'prompt' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
const graph = subgraphNode.graph
if (!graph) throw new Error('Expected subgraph node graph')
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const secondMappedWidget = nodeData?.widgets?.find(
(widget) => widget.slotMetadata?.index === 1
)
if (!secondMappedWidget)
throw new Error('Expected mapped widget for slot 1')
expect(secondMappedWidget.name).not.toBe('stale_widget')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_name'
(w) => w.name === 'ckpt_input'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(

View File

@@ -3,17 +3,16 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -25,12 +24,12 @@ 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 { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -38,7 +37,8 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -60,7 +60,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
@@ -81,17 +81,12 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
@@ -99,10 +94,14 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -143,26 +142,20 @@ export interface GraphNodeManager {
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
const targetId = widget.widgetId
if (!targetId) return
const store = useWidgetValueStore()
const control = store.getWidgetControl(targetId)
if (!control) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
value: normalizeControlOption(control.mode),
update: (value) =>
store.updateWidgetControl(targetId, {
mode: normalizeControlOption(value)
})
}
}
@@ -214,73 +207,81 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
isDOMWidget: boolean
sourceExecutionId?: string
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -294,67 +295,23 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
name,
type: effectiveWidget.type,
...sharedEnhancements,
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -370,6 +327,24 @@ function safeWidgetMapper(
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
@@ -471,14 +446,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -534,7 +511,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
@@ -812,7 +789,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
@@ -265,16 +264,8 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
const [widgetName] = hoveredWidget.value ?? []
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -1,11 +1,21 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
@@ -54,4 +64,43 @@ describe('subgraph pricing', () => {
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -2,9 +2,14 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: { linkIds?: number[] }
}
const componentIconSvg = new Image()
componentIconSvg.src =
@@ -95,11 +100,20 @@ export const usePriceBadge = () => {
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
}
return overrides
}

View File

@@ -1,12 +1,19 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { WidgetState } from '@/types/widgetState'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
return {
name,
type: 'INPUT',
value,
nodeId: '1' as NodeId,
options: {},
y: 0
}
}
const isNumber = (v: unknown): v is number => typeof v === 'number'

View File

@@ -2,9 +2,9 @@ import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
import type { WidgetState } from '@/types/widgetState'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,8 +20,8 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -55,39 +54,15 @@ function addInnerNode(
return node
}
function addPromotedHostInput(
function getPromotedInputValue(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
name: string
): TWidgetValue | undefined {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) return undefined
return useWidgetValueStore().getWidget(input.widgetId)?.value as
| TWidgetValue
| undefined
}
function addPrimitiveWithTargets(
@@ -141,29 +116,6 @@ describe('flushProxyWidgetMigration', () => {
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -183,23 +135,61 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
expect(getPromotedInputValue(host, 'seed')).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstText = new LGraphNode('CLIPTextEncode')
const firstSlot = firstText.addInput('text', 'STRING')
firstSlot.widget = { name: 'text' }
firstText.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstText)
const secondText = new LGraphNode('CLIPTextEncode')
const secondSlot = secondText.addInput('text', 'STRING')
secondSlot.widget = { name: 'text' }
secondText.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondText)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [
[String(firstText.id), 'text'],
[String(secondText.id), 'text']
]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [
[String(nestedHost.id), 'text', String(secondText.id)]
]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
@@ -208,43 +198,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
expect(getPromotedInputValue(host, 'seed')).toBe(7)
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
@@ -264,29 +218,25 @@ describe('flushProxyWidgetMigration', () => {
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
it('createSubgraphInput: preserves the source slot label', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('text', 'STRING')
slot.label = 'renamed_from_sidepanel'
slot.widget = { name: 'text' }
n.addWidget('text', 'text', '', () => {})
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
host.properties.proxyWidgets = [[String(inner.id), 'text']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
const promotedInput = host.inputs.find((input) => input.name === 'text')
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
expect(
promotedInput?.widgetId
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
: undefined
).toBe('renamed_from_sidepanel')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
@@ -361,8 +311,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
expect(getPromotedInputValue(host, 'value')).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
@@ -375,8 +324,7 @@ describe('flushProxyWidgetMigration', () => {
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
expect(getPromotedInputValue(host, 'value')).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
@@ -474,10 +422,8 @@ describe('flushProxyWidgetMigration', () => {
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
})
})

View File

@@ -1,6 +1,6 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
@@ -8,6 +8,7 @@ import {
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
@@ -27,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
@@ -93,23 +95,24 @@ function resolveSourceWidget(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
if (sourceNode.isSubgraphNode()) {
const input = sourceNode.inputs.find((input) => {
const target = resolveSubgraphInputTarget(sourceNode, input.name)
if (disambiguatingSourceNodeId) {
return (
target?.widgetName === sourceWidgetName &&
target.nodeId === disambiguatingSourceNodeId
)
}
if (input.name === sourceWidgetName) return true
return target?.widgetName === sourceWidgetName
})
// Store-backed projection for a promoted input on a nested subgraph node:
// getSlotFromWidget locates the backing slot by widgetId.
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
}
const widgets = sourceNode.widgets
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
@@ -300,19 +303,6 @@ function classify(
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
@@ -373,19 +363,23 @@ function classify(
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
function applyHostValueToInput(
input: INodeInputSlot,
entry: PendingEntry
): boolean {
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
}
function applyHostLabelToInput(
input: INodeInputSlot,
label: string | undefined
): void {
if (label === undefined) return
input.label = label
if (!input.widgetId) return
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = label
}
function addUniqueSubgraphInput(
@@ -422,10 +416,9 @@ function repairAlreadyLinked(
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
if (!applyHostValueToInput(hostInput, entry)) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
@@ -480,11 +473,10 @@ function repairCreateSubgraphInput(
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
if (hostInput) {
applyHostLabelToInput(hostInput, slot.label)
applyHostValueToInput(hostInput, entry)
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
@@ -649,22 +641,19 @@ function repairPrimitive(
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
if (hostInput) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
applyHostValueToInput(hostInput, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
applyHostValueToInput(hostInput, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false

View File

@@ -0,0 +1,118 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
/**
* Where a promoted subgraph input is sourced from inside the subgraph. The
* interior node id + widget name that the host input slot forwards to. Resolved
* by walking the live link, so it is authoritative derived data — never stored
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
widgetName: string
}
/**
* The interior source of a host input slot, or undefined when the slot is not a
* promoted widget input.
*/
export function promotedInputSource(
node: LGraphNode,
input: INodeInputSlot
): PromotedSource | undefined {
if (!input.widgetId) return undefined
return resolveSubgraphInputTarget(node, input.name)
}
/** The host input slot backing a projected widget, matched by widgetId. */
export function inputForWidget(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
* list does not reactively rebuild — and re-key — on every value edit.
*
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
* `label` is the mutable display label. Returns null when the input is not a
* promoted widget input.
*/
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
const id = input.widgetId
if (!id) return null
const store = useWidgetValueStore()
return {
get name() {
return store.getWidget(id)?.name ?? input.name
},
get label() {
return store.getWidget(id)?.label ?? input.label ?? input.name
},
set label(next) {
const state = store.getWidget(id)
if (state) state.label = next
},
get y() {
return store.getWidget(id)?.y ?? 0
},
set y(next) {
const state = store.getWidget(id)
if (state) state.y = next
},
widgetId: id,
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
},
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
// so the value setter above is never invoked; BaseWidget.setValue writes its
// own local state and then calls this callback, which is the only bridge
// back to the store.
callback(next) {
store.setValue(id, next)
}
}
}
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
return node.inputs.flatMap((input) => {
const widget = promotedInputWidget(input)
return widget ? [widget] : []
})
}

View File

@@ -1,31 +1,17 @@
import type { LGraphNode } 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 { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* A persisted promotion's source identity: the interior node + widget a host
* subgraph input was promoted from. Used by the migration/schema layer, where
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -1,100 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isPromotedWidgetView } from './promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNumericInteriorNode(initialValue: number) {
const node = new LGraphNode('Interior')
const input = node.addInput('value', 'number')
node.addOutput('out', 'number')
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget }
}
describe('PromotedWidgetView — host-wins semantics', () => {
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior, widget: interiorWidget } =
createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
const viewA = hostA.widgets.find(isPromotedWidgetView)
const viewB = hostB.widgets.find(isPromotedWidgetView)
if (!viewA || !viewB)
throw new Error('Expected promoted views on both hosts')
viewA.value = 7
expect(viewA.value).toBe(7)
expect(interiorWidget.value).toBe(42)
expect(viewB.value).toBe(42)
})
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior } = createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
type: 'number',
value: 42,
options: {},
label: undefined,
serialize: true,
disabled: false
})
const host = createTestSubgraphNode(subgraph, { id: 200 })
const view = host.widgets.find(isPromotedWidgetView)
if (!view) throw new Error('Expected promoted view on host')
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -1,614 +0,0 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
identityName
)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolveDeepest()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.setHostWidgetState(value)
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
const state = this.getWidgetState()
if (state) state.label = value
}
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -3,22 +3,46 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
}) {
return host.inputs
.filter((input) => input.widgetId)
.map((input) => input.name)
}
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
return host.widgets?.map((widget) => widget.name) ?? []
}
function writePromotedInputValue(
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string,
value: IBaseWidget['value']
) {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const widget = promotedInputWidget(input)
if (!widget) throw new Error(`Missing promoted input ${name}`)
return widget
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
@@ -31,11 +55,9 @@ import {
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
@@ -168,15 +190,18 @@ describe('pruneDisconnected', () => {
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const keptWidgetId = subgraphNode.inputs.find(
(input) => input.name === 'kept'
)?.widgetId
if (!keptWidgetId) throw new Error('Missing kept widgetId')
for (const input of [missingWidgetInput, missingNodeInput]) {
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
hostInput.widgetId = keptWidgetId
}
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -301,6 +326,25 @@ describe('promoteRecommendedWidgets', () => {
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('preserves the source slot label when promoting a value widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Prompt')
const input = interiorNode.addInput('text', 'STRING')
input.label = 'renamed_from_sidepanel'
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
input.widget = { name: textWidget.name }
subgraph.add(interiorNode)
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
expect(hostInput?.label).toBe('renamed_from_sidepanel')
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
'renamed_from_sidepanel'
)
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -485,79 +529,45 @@ describe('isLinkedPromotion', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
const node = new LGraphNode('Source')
const input = node.addInput(widgetName, 'STRING')
const widget = node.addWidget('text', widgetName, '', () => {})
input.widget = { name: widget.name }
host.subgraph.add(node)
promoteValueWidgetViaSubgraphInput(host, node, widget)
return node
}
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
it('returns true for a linked promotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
})
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
})
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
it('identifies linked widgets across different inputs', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
})
})
@@ -607,17 +617,13 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
expect(promotedInputNames(host)).toEqual(['first', 'second'])
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
@@ -637,15 +643,13 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
writePromotedInputValue(host, 'first', 'first value')
writePromotedInputValue(host, 'second', 'second value')
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -727,15 +731,21 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
writePromotedInputValue(host, 'text', 'first value')
writePromotedInputValue(host, 'text_1', 'second value')
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
const firstPromotedWidget = promotedWidgetRef(host, 'text')
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
reorderSubgraphInputsByWidgetOrder(host, [
secondPromotedWidget,
firstPromotedWidget
])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'text_1',
'text'
])
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -775,10 +785,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedViewsBefore = host.widgets.length
const promotedInputId = hostInput.widgetId
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedViewsBefore).toBeGreaterThan(0)
expect(promotedInputId).toBeDefined()
demoteWidget(interiorNode, interiorWidget, [host])
@@ -788,13 +798,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
})
it('removes the slot entirely when host slot has no external link', () => {
@@ -812,12 +818,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
demoteWidget(nodeB, widgetB, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
@@ -825,15 +826,19 @@ describe('demoteWidget — axiomatic projection retraction', () => {
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
for (const input of innerHost.inputs) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
@@ -841,12 +846,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
'text_1'
])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
@@ -863,66 +863,19 @@ describe('disambiguated nested promotion identity', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const { host: innerHost } = buildDuplicateNamePromotion()
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
subgraph.add(innerHost)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
expect(
promoteValueWidgetViaSubgraphInput(
host,
innerHost,
promotedWidgetRef(innerHost, 'text_1')
).ok
).toBe(true)
pruneDisconnected(host)
@@ -956,9 +909,13 @@ describe('disambiguated nested promotion identity', () => {
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
for (const input of innerHost.inputs) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
).toBe(true)
}

View File

@@ -1,6 +1,6 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,7 +18,9 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -46,16 +48,47 @@ export function findHostInputForPromotion(
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
const source = input._subgraphSlot
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
: undefined
return (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
source?.sourceNodeId === sourceNodeId &&
source.sourceWidgetName === sourceWidgetName
)
})
}
function resolvePromotionSource(
subgraphNode: SubgraphNode,
subgraphInput: { linkIds: readonly number[] }
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraphNode.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
@@ -78,13 +111,12 @@ export function reorderSubgraphInputsByName(
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
@@ -101,37 +133,48 @@ function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
const widgetValues = subgraphNode.inputs.map((input) => {
const id = input?.widgetId
if (!id) return undefined
const value = useWidgetValueStore().getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
const id = subgraphNode.inputs[newIndex]?.widgetId
if (value === undefined || !id) continue
useWidgetValueStore().setValue(id, value)
}
}
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
function isSamePromotedInput(
subgraphNode: SubgraphNode,
inputIndex: number,
orderedWidget: Pick<IBaseWidget, 'widgetId'>
): boolean {
const input = subgraphNode.inputs[inputIndex]
const linkedInput = input?._subgraphSlot
if (!input || !linkedInput) return false
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
for (const linkId of linkedInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
const { inputNode, input: targetInput } = link.resolve(
subgraphNode.subgraph
)
if (!inputNode || !targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (targetWidget === orderedWidget) return true
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
}
return false
}
function isPreviewExposed(
@@ -168,13 +211,9 @@ function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
sourceWidgetName: getWidgetName(widget)
}
}
@@ -211,15 +250,53 @@ export function promoteValueWidgetViaSubgraphInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
subgraphInput.label = sourceSlot.label
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (hostInput) hostInput.label = sourceSlot.label
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
return { ok: true }
}
function seedNestedPromotedInputState(
subgraphNode: SubgraphNode,
inputName: string,
sourceSlot: { widgetId?: WidgetId; label?: string }
): void {
if (!sourceSlot.widgetId) return
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot?.name === inputName
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
@@ -283,6 +360,32 @@ export function promoteWidget(
})
}
/**
* Removes the host input projecting a linked promotion identified by source.
* Returns true when an input was found and demoted.
*/
export function demotePromotedInput(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
if (!subgraphNode.subgraph) return false
const hostInput = findHostInputForPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (!linkedInput) return false
if (hostInput.link != null) {
linkedInput.disconnect()
} else {
subgraphNode.subgraph.removeInput(linkedInput)
}
return true
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -292,21 +395,7 @@ export function demoteWidget(
for (const parent of parents) {
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (demotePromotedInput(parent, source)) continue
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
@@ -505,37 +594,19 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
const removedEntries: PromotedWidgetSource[] = []
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const source = resolvePromotionSource(subgraphNode, input)
if (source) return false
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === widget.sourceWidgetName
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hasWidget) {
removedEntries.push(widget)
}
return !hasWidget
if (!hostInput?.widgetId && !hostInput?._widget) return false
removedEntries.push({
sourceNodeId: String(subgraphNode.id),
sourceWidgetName: input.name
})
return true
})
for (const input of staleInputs) {

View File

@@ -1,11 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
@@ -24,15 +22,6 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
}
@@ -47,55 +36,10 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
@@ -114,102 +58,86 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'outer'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'x', type: '*' }]
})
})
const leaf = new LGraphNode('Leaf')
const leafInput = leaf.addInput('x', '*')
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
values: ['a', 'b']
})
leafInput.widget = { name: 'seed' }
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'y', type: '*' }]
})
outerSubgraph.add(innerNode)
innerNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
outerNode,
String(innerNode.id),
'x'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
expect(result.resolved.node.id).toBe(leaf.id)
expect(result.resolved.widget.name).toBe('seed')
expect(result.resolved.widget.type).toBe('combo')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
const recursiveInput = { name: 'x', link: 1 }
const recursiveNode = fromAny<LGraphNode, unknown>({
id: 11,
inputs: [recursiveInput],
isSubgraphNode: () => true,
subgraph: {
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
getLink: () => ({
resolve: () => ({ inputNode: recursiveNode })
}),
getNodeById: () => recursiveNode
}
})
const host = fromAny<SubgraphNode, unknown>({
isSubgraphNode: () => true,
subgraph: {
getNodeById: () => recursiveNode
}
})
const result = resolveConcretePromotedWidget(host, '11', 'x')
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
})
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
const subgraphs = Array.from({ length: 102 }, () =>
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
for (let index = 0; index < subgraphs.length - 1; index++) {
const current = subgraphs[index]
const next = subgraphs[index + 1]
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
current.add(nextNode)
nextNode._internalConfigureAfterSlots()
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
}
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
const result = resolveConcretePromotedWidget(host, '1', 'x')
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'

View File

@@ -1,8 +1,7 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -41,6 +40,17 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
if (sourceNode.isSubgraphNode()) {
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
if (!target) {
return { status: 'failure', failure: 'missing-widget' }
}
currentHost = sourceNode
currentNodeId = target.nodeId
currentWidgetName = target.widgetName
continue
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
@@ -48,39 +58,15 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-widget' }
}
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
@@ -91,20 +77,3 @@ export function resolveConcretePromotedWidget(
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -1,8 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function hasWidgetNode(
widget: IBaseWidget
): widget is IBaseWidget & { node: LGraphNode } {
return 'node' in widget && !!widget.node
}

View File

@@ -1,7 +1,5 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
let nextWidgetRenderKeyId = 0
@@ -9,9 +7,7 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
const key = `widget:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -0,0 +1,99 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function addControlledNode(
graph: LGraph,
type: 'number' | 'combo',
filter?: string
): LGraphNode {
const node = new LGraphNode('Controlled')
node.id = 1
const widget = node.addWidget(
type,
'seed',
type === 'combo' ? 'a' : 1,
() => {},
{
values: ['a', 'b', 'c']
}
)
graph.add(node)
useWidgetValueStore().registerWidgetControl(widget.widgetId!, {
mode: 'increment',
filter
})
return node
}
describe('control projection on the classic canvas', () => {
let graph: LGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
graph = new LGraph()
graph.id = 'graph-a'
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
it('interleaves a control row after a controlled widget', () => {
const node = addControlledNode(graph, 'number')
const rendered = node.getRenderWidgets()
expect(rendered.map((w) => w.name)).toEqual([
'seed',
'control_after_generate'
])
expect(rendered[1].type).toBe('combo')
expect(rendered[1].value).toBe('increment')
})
it('adds a filter row only for combo targets that carry a filter', () => {
const node = addControlledNode(graph, 'combo', '')
expect(node.getRenderWidgets().map((w) => w.name)).toEqual([
'seed',
'control_after_generate',
'control_filter_list'
])
})
it('reads and writes the control component through the projection', () => {
const node = addControlledNode(graph, 'number')
const store = useWidgetValueStore()
const control = node.getRenderWidgets()[1]
control.callback?.(
'randomize',
undefined as never,
node,
[0, 0],
undefined as never
)
expect(store.getWidgetControl(node.widgets![0].widgetId!)?.mode).toBe(
'randomize'
)
expect(control.value).toBe('randomize')
})
it('omits projections in Vue node mode', () => {
const node = addControlledNode(graph, 'number')
LiteGraph.vueNodesMode = true
expect(node.getRenderWidgets().map((w) => w.name)).toEqual(['seed'])
})
it('drops the control row when the component is removed', () => {
const node = addControlledNode(graph, 'number')
useWidgetValueStore().deleteWidgetControl(node.widgets![0].widgetId!)
expect(node.getRenderWidgets().map((w) => w.name)).toEqual(['seed'])
})
})

View File

@@ -0,0 +1,95 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { t } from '@/i18n'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { COMBO_CONTROL_MODES, NUMBER_CONTROL_MODES } from './valueControl'
import type { ValueControlMode } from './valueControl'
const projectionCache = new WeakMap<LGraphNode, Map<string, IBaseWidget>>()
function getCache(node: LGraphNode): Map<string, IBaseWidget> {
let cache = projectionCache.get(node)
if (!cache) {
cache = new Map()
projectionCache.set(node, cache)
}
return cache
}
function createModeProjection(
targetId: WidgetId,
isCombo: boolean
): IBaseWidget {
const store = useWidgetValueStore()
return {
type: 'combo',
name: 'control_after_generate',
options: {
values: [...(isCombo ? COMBO_CONTROL_MODES : NUMBER_CONTROL_MODES)],
serialize: false
},
serialize: false,
y: 0,
get label() {
return t('g.control_after_generate')
},
get value() {
return store.getWidgetControl(targetId)?.mode ?? 'fixed'
},
callback(next) {
store.updateWidgetControl(targetId, { mode: next as ValueControlMode })
}
} as IBaseWidget
}
function createFilterProjection(targetId: WidgetId): IBaseWidget {
const store = useWidgetValueStore()
return {
type: 'string',
name: 'control_filter_list',
options: { serialize: false },
serialize: false,
y: 0,
get value() {
return store.getWidgetControl(targetId)?.filter ?? ''
},
callback(next) {
store.updateWidgetControl(targetId, { filter: String(next) })
}
} as IBaseWidget
}
/**
* Render-only widgets for a target's control component, drawn on the classic
* canvas without ever entering `node.widgets`. Cached per node so layout fields
* (`y`, `last_y`) persist across arrange/draw/hit-test.
*/
export function getControlProjections(
node: LGraphNode,
target: IBaseWidget
): IBaseWidget[] {
const targetId = target.widgetId
if (!targetId) return []
const control = useWidgetValueStore().getWidgetControl(targetId)
if (!control) return []
const cache = getCache(node)
const modeKey = `${targetId}:mode`
let mode = cache.get(modeKey)
if (!mode) {
mode = createModeProjection(targetId, target.type === 'combo')
cache.set(modeKey, mode)
}
if (control.filter === undefined) return [mode]
const filterKey = `${targetId}:filter`
let filter = cache.get(filterKey)
if (!filter) {
filter = createFilterProjection(targetId)
cache.set(filterKey, filter)
}
return [mode, filter]
}

View File

@@ -2,11 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
import {
computeNextControlledValue,
isValueControlWidget
} from './valueControl'
import { computeNextControlledValue } from './valueControl'
const makeNumberWidget = (
value: number,
@@ -27,32 +23,6 @@ const makeComboWidget = (value: string, values: string[]): IBaseWidget =>
options: { values }
}) as unknown as IBaseWidget
describe('isValueControlWidget', () => {
it('returns true for a marked widget with both lifecycle hooks', () => {
const widget = {
[IS_CONTROL_WIDGET]: true,
beforeQueued: () => {},
afterQueued: () => {}
} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(true)
})
it('returns false when the marker symbol is missing', () => {
const widget = {
beforeQueued: () => {},
afterQueued: () => {}
} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(false)
})
it('returns false when lifecycle hooks are missing', () => {
const widget = {
[IS_CONTROL_WIDGET]: true
} as unknown as IBaseWidget
expect(isValueControlWidget(widget)).toBe(false)
})
})
describe('computeNextControlledValue (number)', () => {
it('returns undefined for fixed mode', () => {
expect(

View File

@@ -1,52 +1,48 @@
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
type ValueControlMode =
export type ValueControlMode =
| 'fixed'
| 'increment'
| 'increment-wrap'
| 'decrement'
| 'randomize'
export function nextValueForLinkedTarget(params: {
target: IBaseWidget
linkedWidgets: IBaseWidget[] | undefined
nodeId: unknown
isPartialExecution: boolean | undefined
}): IBaseWidget['value'] | undefined {
if (params.isPartialExecution) return undefined
const linked = params.linkedWidgets
if (!linked) return undefined
/** Control modes offered for combo targets (combos add wrap-around). */
export const COMBO_CONTROL_MODES: readonly ValueControlMode[] = [
'fixed',
'increment',
'increment-wrap',
'decrement',
'randomize'
]
const controlWidget = linked.find(isValueControlWidget)
if (!controlWidget) return undefined
/** Control modes offered for numeric targets. */
export const NUMBER_CONTROL_MODES: readonly ValueControlMode[] = [
'fixed',
'increment',
'decrement',
'randomize'
]
const comboFilter = linked.find(
(w) => w !== controlWidget && w.type === 'string'
)
const filterValue =
typeof comboFilter?.value === 'string' ? comboFilter.value : undefined
const VALUE_CONTROL_MODES: ReadonlySet<string> = new Set(COMBO_CONTROL_MODES)
const mode = controlWidget.value as ValueControlMode
return computeNextControlledValue(params.target, mode, {
comboFilter: filterValue,
nodeId: params.nodeId
})
export function isValueControlMode(value: unknown): value is ValueControlMode {
return typeof value === 'string' && VALUE_CONTROL_MODES.has(value)
}
/**
* The minimal widget shape needed to advance a controlled value. Matches both a
* litegraph widget and a `WidgetState` row from the widget value store.
*/
interface ValueControlTarget {
type: IBaseWidget['type']
value?: unknown
options: IBaseWidget['options']
}
const SAFE_INTEGER_MAX = 1125899906842624
const SAFE_INTEGER_MIN = -1125899906842624
export function isValueControlWidget(widget: IBaseWidget): boolean {
return (
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
typeof widget.beforeQueued === 'function' &&
typeof widget.afterQueued === 'function'
)
}
function buildComboFilter(
filter: string | undefined,
nodeId?: unknown
@@ -71,13 +67,13 @@ function buildComboFilter(
}
export function computeNextControlledValue(
target: IBaseWidget,
target: ValueControlTarget,
mode: ValueControlMode,
options: { comboFilter?: string; nodeId?: unknown } = {}
): IBaseWidget['value'] | undefined {
if (mode === 'fixed') return undefined
if (isComboWidget(target)) {
if (target.type === 'combo') {
return computeNextComboValue(target, mode, options)
}
@@ -85,7 +81,7 @@ export function computeNextControlledValue(
}
function computeNextComboValue(
target: IBaseWidget,
target: ValueControlTarget,
mode: ValueControlMode,
{ comboFilter, nodeId }: { comboFilter?: string; nodeId?: unknown }
): IBaseWidget['value'] | undefined {
@@ -132,7 +128,7 @@ function computeNextComboValue(
}
function computeNextNumberValue(
target: IBaseWidget,
target: ValueControlTarget,
mode: ValueControlMode
): number | undefined {
if (typeof target.value !== 'number') return undefined

View File

@@ -0,0 +1,131 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function addControlledNode(
graph: LGraph,
configure: (node: LGraphNode) => void
): LGraphNode {
const node = new LGraphNode('TestNode')
node.id = 1
configure(node)
graph.add(node)
return node
}
describe('widget control positional (de)serialization', () => {
let graph: LGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
graph = new LGraph()
})
it('loads legacy [target, mode] for a number control without shifting later widgets', () => {
const node = addControlledNode(graph, (n) => {
const seed = n.addWidget('number', 'seed', 0, null, {}) as IBaseWidget
n.addWidget('text', 'prompt', '', null, {})
seed.controlConfig = { mode: 'randomize', hasFilter: false }
})
node.configure({
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [12345, 'increment', 'a prompt']
})
const store = useWidgetValueStore()
expect(node.widgets![0].value).toBe(12345)
expect(node.widgets![1].value).toBe('a prompt')
expect(store.getWidgetControl(node.widgets![0].widgetId!)?.mode).toBe(
'increment'
)
})
it('loads legacy [target, mode, filter] for a combo control without shifting later widgets', () => {
const node = addControlledNode(graph, (n) => {
const ckpt = n.addWidget('combo', 'ckpt', 'a', null, {
values: ['a', 'b', 'c']
}) as IBaseWidget
n.addWidget('text', 'prompt', '', null, {})
ckpt.controlConfig = { mode: 'increment', hasFilter: true }
})
node.configure({
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: ['b', 'increment-wrap', 'safetensors', 'a prompt']
})
const store = useWidgetValueStore()
const control = store.getWidgetControl(node.widgets![0].widgetId!)
expect(node.widgets![0].value).toBe('b')
expect(node.widgets![1].value).toBe('a prompt')
expect(control?.mode).toBe('increment-wrap')
expect(control?.filter).toBe('safetensors')
})
it('does not consume a following widget value when a legacy layout omits the control slot', () => {
const node = addControlledNode(graph, (n) => {
const seed = n.addWidget('number', 'seed', 0, null, {}) as IBaseWidget
n.addWidget('text', 'prompt', '', null, {})
seed.controlConfig = { mode: 'randomize', hasFilter: false }
})
node.configure({
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [12345, 'a prompt']
})
expect(node.widgets![0].value).toBe(12345)
expect(node.widgets![1].value).toBe('a prompt')
})
it('round-trips control state through the classic positional layout', () => {
const node = addControlledNode(graph, (n) => {
n.serialize_widgets = true
const ckpt = n.addWidget('combo', 'ckpt', 'a', null, {
values: ['a', 'b', 'c']
}) as IBaseWidget
n.addWidget('text', 'prompt', '', null, {})
ckpt.controlConfig = { mode: 'increment', hasFilter: true }
})
node.configure({
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: ['b', 'increment-wrap', 'safetensors', 'a prompt']
})
expect(node.serialize().widgets_values).toEqual([
'b',
'increment-wrap',
'safetensors',
'a prompt'
])
})
})

View File

@@ -0,0 +1,67 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { isValueControlMode } from './valueControl'
/**
* Registers the control component for a target widget from its transient
* `controlConfig`. Idempotent and safe to call before or after the widget is
* bound to a node, so control registration follows the same deferral as value
* registration.
*/
export function registerWidgetControlFromConfig(widget: IBaseWidget): void {
const config = widget.controlConfig
if (!config) return
const targetId = widget.widgetId
if (!targetId) return
useWidgetValueStore().registerWidgetControl(targetId, {
mode: config.mode,
filter: config.hasFilter ? '' : undefined
})
}
/**
* Appends a target's control values to `widgets_values`, preserving the classic
* positional layout `[target, mode, filter?]` now that control is a component
* rather than a widget. Inverse of {@link applyControlValues}.
*/
export function appendControlValues(
targetId: WidgetId | undefined,
values: unknown[]
): void {
if (!targetId) return
const control = useWidgetValueStore().getWidgetControl(targetId)
if (!control) return
values.push(control.mode)
if (control.filter !== undefined) values.push(control.filter)
}
/**
* Reads control values back from `widgets_values` at `index`, consuming the same
* slots {@link appendControlValues} writes, and returns the next index.
*/
export function applyControlValues(
targetId: WidgetId | undefined,
values: readonly unknown[],
index: number
): number {
if (!targetId) return index
const store = useWidgetValueStore()
const control = store.getWidgetControl(targetId)
if (!control) return index
let next = index
const mode = values[next]
if (!isValueControlMode(mode)) return next
store.updateWidgetControl(targetId, { mode })
next++
const filter = values[next]
if (control.filter !== undefined && typeof filter === 'string') {
store.updateWidgetControl(targetId, { filter })
next++
}
return next
}

View File

@@ -0,0 +1,152 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { ValueControlMode } from '@/core/graph/widgets/control/valueControl'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { runWidgetControl } from './widgetControlSystem'
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
})
}))
function addSeedNode(
graph: LGraph,
{
mode = 'increment',
value = 1
}: { mode?: ValueControlMode; value?: number } = {}
): LGraphNode {
const node = new LGraphNode('SeedNode')
node.id = 1
const seed = node.addWidget('number', 'seed', value, () => {}, {
min: 0,
max: 1_000_000,
step2: 1
})
graph.add(node)
useWidgetValueStore().registerWidgetControl(seed.widgetId!, { mode })
return node
}
function seedValue(node: LGraphNode): unknown {
const store = useWidgetValueStore()
return store.getWidget(node.widgets![0].widgetId!)?.value
}
describe('runWidgetControl', () => {
let graph: LGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
controlMode.value = 'after'
graph = new LGraph()
graph.id = 'graph-a'
})
it('increments a controlled value after queueing', () => {
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(2)
})
it('leaves the value unchanged when the mode is fixed', () => {
const node = addSeedNode(graph, { mode: 'fixed' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('does not run on a target whose input is link-fed', () => {
const node = addSeedNode(graph, { mode: 'increment' })
node.addInput('seed', 'number', { link: 1, widget: { name: 'seed' } })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('does not run during partial execution', () => {
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after', { isPartialExecution: true })
expect(seedValue(node)).toBe(1)
})
it('skips the first queue in before mode, then advances', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(1)
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(2)
})
it('ignores after-phase work when in before mode', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('applies a combo filter when advancing a combo value', () => {
const store = useWidgetValueStore()
const node = new LGraphNode('CkptNode')
node.id = 1
const ckpt = node.addWidget('combo', 'ckpt', 'a.safetensors', () => {}, {
values: ['a.safetensors', 'b.ckpt', 'c.safetensors']
})
graph.add(node)
store.registerWidgetControl(ckpt.widgetId!, {
mode: 'increment',
filter: 'safetensors'
})
runWidgetControl(graph, 'after')
expect(store.getWidget(ckpt.widgetId!)?.value).toBe('c.safetensors')
})
it('only advances controls belonging to the queued graph', () => {
const node = addSeedNode(graph, { mode: 'increment' })
const otherGraph = new LGraph()
otherGraph.id = 'graph-b'
const otherNode = addSeedNode(otherGraph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(2)
expect(seedValue(otherNode)).toBe(1)
})
it('preserves the before-mode skip when the widget re-registers', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(1)
const seed = node.widgets![0]
if (isNodeBindable(seed)) seed.setNodeId(node.id)
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(2)
})
})

View File

@@ -0,0 +1,67 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { parseWidgetId, widgetId } from '@/types/widgetId'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { computeNextControlledValue } from './valueControl'
type WidgetControlPhase = 'before' | 'after'
/**
* Widget ids whose input slot is currently link-fed, so their value comes from
* upstream and control must not advance it. Derived live from the graph.
*/
function collectLinkFedTargets(graph: LGraph): Set<WidgetId> {
const graphId = graph.rootGraph.id
const linkFed = new Set<WidgetId>()
forEachNode(graph, (node) => {
for (const input of node.inputs ?? []) {
if (input.link == null) continue
if (input.widgetId) {
linkFed.add(input.widgetId)
} else if (input.widget?.name) {
linkFed.add(widgetId(graphId, node.id, input.widget.name))
}
}
})
return linkFed
}
/** Advances the graph's controlled widget store values at the given queue phase. */
export function runWidgetControl(
graph: LGraph,
phase: WidgetControlPhase,
{ isPartialExecution }: { isPartialExecution?: boolean } = {}
): void {
if (isPartialExecution) return
const runBefore =
useSettingStore().get('Comfy.WidgetControlMode') === 'before'
if (phase === 'before' && !runBefore) return
if (phase === 'after' && runBefore) return
const store = useWidgetValueStore()
const linkFed = collectLinkFedTargets(graph)
for (const [targetId, control] of store.getWidgetControls(
graph.rootGraph.id
)) {
const target = store.getWidget(targetId)
if (!target || linkFed.has(targetId)) continue
if (phase === 'before') {
const firstRun = !control.hasExecuted
store.updateWidgetControl(targetId, { hasExecuted: true })
if (firstRun) continue
}
const next = computeNextControlledValue(target, control.mode, {
comboFilter: control.filter,
nodeId: parseWidgetId(targetId).nodeId
})
if (next === undefined) continue
store.setValue(targetId, next)
}
}

View File

@@ -24,6 +24,7 @@ import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
@@ -190,7 +191,9 @@ function dynamicComboWidget(
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
return useWidgetValueStore().getWidget(
widgetId(graphId, node.id, widget.name)
)
}
Object.defineProperty(widget, 'value', {
get() {

View File

@@ -0,0 +1,34 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
/**
* Transient per-frame widget render state produced by the canvas arrange and
* draw passes and read back during draw and hit-testing.
*
* Held in a non-reactive {@link WeakMap} keyed by the widget object — a
* frame-stable side cache, deliberately separate from the reactive value store.
* Keeping this churning per-frame state out of the widget instance moves
* widgets closer to plain data whose render state lives in a queryable cache,
* and lets entries be released automatically when a widget is collected.
*/
export interface WidgetLayout {
/** Y offset of the widget within its node, assigned during arrange. */
y: number
/** Y offset captured at draw time; hit-testing reads this. */
last_y?: number
/** Height computed during arrange. */
computedHeight?: number
/** Disabled state derived each draw from `disabled` and slot links; dims rendering. */
computedDisabled?: boolean
}
const widgetLayouts = new WeakMap<IBaseWidget, WidgetLayout>()
/** Returns the widget's layout record, creating an empty one on first access. */
export function getWidgetLayout(widget: IBaseWidget): WidgetLayout {
let layout = widgetLayouts.get(widget)
if (!layout) {
layout = { y: 0 }
widgetLayouts.set(widget, layout)
}
return layout
}

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
import { widgetId } from '@/types/widgetId'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
applyFirstWidgetValueToGraph(this, extraLinks)
@@ -51,16 +52,15 @@ function onCustomComboCreated(this: LGraphNode) {
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
)?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
widgetId(app.rootGraph.id, node.id, widgetName)
)
if (state) state.value = v
updateCombo()

View File

@@ -20,6 +20,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { type NodeLocatorId } from '@/types'
import { widgetId } from '@/types/widgetId'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
@@ -152,16 +153,16 @@ app.registerExtension({
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
)?.value as string) ?? ''
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
node.id,
inputName
widgetId(graphId, node.id, inputName)
)
if (widgetState) widgetState.value = v
}

View File

@@ -21,8 +21,10 @@ import {
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { applyControlValues } from '@/core/graph/widgets/control/widgetControl'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
@@ -228,35 +230,18 @@ export class PrimitiveNode extends LGraphNode {
!inputData?.[1]?.control_after_generate &&
(widget.type === 'number' || widget.type === 'combo')
) {
let control_value = this.widgets_values?.[1]
if (!control_value) {
control_value = 'fixed'
}
const savedMode = this.widgets_values?.[1]
addValueControlWidgets(
this,
widget,
control_value as string,
undefined,
inputData
typeof savedMode === 'string' ? savedMode : 'fixed'
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
const filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter
}
applyControlValues(widget.widgetId, this.widgets_values ?? [], 1)
}
// Restore any saved control values
// Restore control state saved when the node was recreated.
const controlValues = this.controlValues
if (
this.widgets &&
this.lastType === this.widgets[0]?.type &&
controlValues?.length === this.widgets.length - 1
) {
for (let i = 0; i < controlValues.length; i++) {
this.widgets[i + 1].value = controlValues[i]
}
if (this.lastType === this.widgets?.[0]?.type && controlValues?.length) {
applyControlValues(this.widgets?.[0]?.widgetId, controlValues, 0)
}
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
@@ -388,13 +373,18 @@ export class PrimitiveNode extends LGraphNode {
}
}
// Temporarily store the current values in case the node is being recreated
// Temporarily store control state in case the node is being recreated
// e.g. by group node conversion
this.controlValues = []
this.lastType = this.widgets[0]?.type
for (let i = 1; i < this.widgets.length; i++) {
this.controlValues.push(this.widgets[i].value)
}
const valueId = this.widgets[0]?.widgetId
const control = valueId
? useWidgetValueStore().getWidgetControl(valueId)
: undefined
this.controlValues = control
? control.filter !== undefined
? [control.mode, control.filter]
: [control.mode]
: []
setTimeout(() => {
delete this.lastType
delete this.controlValues

View File

@@ -16,6 +16,7 @@ import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
createTestSubgraphData,
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
widgetValueStore.registerWidget(seedWidgetId, {
type: 'number',
value: 1,
options: {},
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
graph.clear()
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)

View File

@@ -1,6 +1,11 @@
import { toValue } from 'vue'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import { getControlProjections } from '@/core/graph/widgets/control/controlProjection'
import {
appendControlValues,
applyControlValues
} from '@/core/graph/widgets/control/widgetControl'
import {
calculateInputSlotPosFromSlot,
getSlotPosition
@@ -93,14 +98,13 @@ import { warnDeprecated } from './utils/feedback'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
import { getWidgetBehavior } from './widgets/widgetBehavior'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
import type { NodeId } from '@/world/entityIds'
// #region Types
export type { NodeId }
export type NodeId = number | string
export type NodeProperty = string | number | boolean | object
@@ -922,6 +926,7 @@ export class LGraphNode
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
i = applyControlValues(widget.widgetId, info.widgets_values, i)
}
}
}
@@ -972,16 +977,19 @@ export class LGraphNode
const { widgets } = this
if (widgets && this.serialize_widgets) {
o.widgets_values = []
for (const [i, widget] of widgets.entries()) {
const values: TWidgetValue[] = []
for (const widget of widgets) {
if (widget.serialize === false) continue
const val = widget?.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
o.widgets_values[i] =
values.push(
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
)
appendControlValues(widget.widgetId, values)
}
o.widgets_values = values
}
if (!o.type && this.constructor.type) o.type = this.constructor.type
@@ -1809,7 +1817,7 @@ export class LGraphNode
// Get widget height & expand size if necessary
let widgets_height = 0
if (widgets?.length) {
for (const widget of widgets) {
for (const widget of this.getRenderWidgets()) {
if (!this.isWidgetVisible(widget)) continue
let widget_height = 0
@@ -2254,8 +2262,9 @@ export class LGraphNode
canvasY: number,
includeDisabled = false
): IBaseWidget | undefined {
const { widgets, pos, size } = this
if (!widgets?.length) return
const { pos, size } = this
const widgets = this.getRenderWidgets()
if (!widgets.length) return
const x = canvasX - pos[0]
const y = canvasY - pos[1]
@@ -3933,7 +3942,21 @@ export class LGraphNode
* Filters out hidden widgets only (not collapsed/advanced).
*/
getLayoutWidgets(): IBaseWidget[] {
return this.widgets?.filter((w) => !w.hidden) ?? []
return this.getRenderWidgets().filter((w) => !w.hidden)
}
/**
* Widgets to draw/lay out/hit-test on the classic canvas: real widgets with
* render-only control rows interleaved. Vue nodes render control separately,
* so projections are omitted there.
*/
getRenderWidgets(): IBaseWidget[] {
const widgets = this.widgets ?? []
if (LiteGraph.vueNodesMode) return [...widgets]
return widgets.flatMap((widget) => [
widget,
...getControlProjections(this, widget)
])
}
/**
@@ -3957,7 +3980,7 @@ export class LGraphNode
if (!this.widgets) return
const nodeWidth = this.size[0]
const { widgets } = this
const widgets = this.getRenderWidgets()
const H = LiteGraph.NODE_WIDGET_HEIGHT
const showText = !lowQuality
ctx.save()
@@ -3983,10 +4006,15 @@ export class LGraphNode
if (typeof widget.draw === 'function') {
widget.draw(ctx, this, width, y, H, lowQuality)
} else {
toConcreteWidget(widget, this, false)?.drawWidget(ctx, {
width,
showText
})
const behavior = getWidgetBehavior(widget.type)
if (behavior) {
behavior.drawWidget(widget, ctx, { width, showText })
} else {
toConcreteWidget(widget, this, false)?.drawWidget(ctx, {
width,
showText
})
}
}
ctx.globalAlpha = editorAlpha
}
@@ -4241,7 +4269,9 @@ export class LGraphNode
if (!widget) continue
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
const pos: [number, number] = [offset, widget.y + offset]
slot.pos = pos
this.inputs[i].pos = pos
this._measureSlot(slot, i, true)
}
}

View File

@@ -1,6 +1,6 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour } from './interfaces'
import { LiteGraph } from './litegraph'
import { litegraph } from './litegraphInstance'
import { RenderShape, TitleMode } from './types/globalEnums'
import { cachedMeasureText } from './utils/textMeasureCache'
@@ -81,12 +81,12 @@ export function strokeShape(
}: IDrawBoundingOptions = {}
): void {
// These param defaults are not compile-time static, and must be re-evaluated at runtime
round_radius ??= LiteGraph.ROUND_RADIUS
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
round_radius ??= litegraph().ROUND_RADIUS
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
// Adjust area if title is transparent
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
area[1] -= height
area[3] += height
}

View File

@@ -1,5 +1,6 @@
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { WidgetId } from '@/types/widgetId'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { ContextMenu } from './ContextMenu'
@@ -362,6 +363,7 @@ export interface IWidgetLocator {
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
widget?: IWidgetLocator
widgetId?: WidgetId
alwaysVisible?: boolean
/**

View File

@@ -7,6 +7,7 @@ import type {
Point,
Size
} from './interfaces'
import { registerLiteGraphInstance } from './litegraphInstance'
import { loadPolyfills } from './polyfills'
import type { CanvasEventDetail } from './types/events'
import type { RenderShape, TitleMode } from './types/globalEnums'
@@ -15,6 +16,7 @@ import type { RenderShape, TitleMode } from './types/globalEnums'
export { Subgraph } from './subgraph/Subgraph'
export const LiteGraph = new LiteGraphGlobal()
registerLiteGraphInstance(LiteGraph)
// Load legacy polyfills
loadPolyfills()

View File

@@ -0,0 +1,26 @@
import type { LiteGraphGlobal } from './LiteGraphGlobal'
/**
* Late-bound holder for the {@link LiteGraphGlobal} singleton.
*
* This module imports `LiteGraphGlobal` as a type only, so it has no runtime
* dependencies. Modules in the widget initialisation chain (e.g. `draw.ts`,
* imported transitively by `BaseWidget`) can read singleton constants through
* {@link litegraph} without importing the `litegraph` barrel — which would
* re-enter the barrel mid-initialisation and evaluate
* `LegacyWidget extends BaseWidget` before `BaseWidget` is defined.
*
* The barrel constructs the singleton and calls {@link registerLiteGraphInstance}.
*/
let instance: LiteGraphGlobal | null = null
export function registerLiteGraphInstance(value: LiteGraphGlobal): void {
instance = value
}
export function litegraph(): LiteGraphGlobal {
if (!instance) {
throw new Error('LiteGraph singleton accessed before initialisation')
}
return instance
}

View File

@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError'
@@ -183,15 +184,14 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Nothing connected
const linkId = subgraphNodeInput.link
if (linkId == null) {
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
if (!widget) return
const id = subgraphNodeInput.widgetId
if (!id) return
// Special case: SubgraphNode widget.
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widget.value }
widgetInfo: { value: useWidgetValueStore().getWidget(id)?.value }
}
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
}
}
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
expect(second).toBe(first)
expect(second[0]).toBe(first[0])
})
test('preserves view identity while reflecting order changes', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(reordered[0]).toBe(firstPass[1])
expect(reordered[1]).toBe(firstPass[0])
})
test('deduplicates by first occurrence and clears stale cache entries', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const first = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(first.map((view) => view.key)).toStrictEqual([
'1:widgetA',
'1:widgetB'
])
manager.reconcile(
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
test('keeps distinct views for same source widget when viewKeys differ', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const views = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(views).toHaveLength(2)
expect(views[0]).not.toBe(views[1])
expect(views[0].key).toBe('1:widgetA:slotA')
expect(views[1].key).toBe('1:widgetA:slotB')
})
test('removeByViewKey removes only the targeted keyed view', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
manager.removeByViewKey('1', 'widgetA', 'slotA')
const secondPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(secondPass[0]).not.toBe(firstPass[0])
expect(secondPass[1]).toBe(firstPass[1])
})
})

View File

@@ -1,118 +0,0 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
/**
* Reconciles promoted widget entries to stable view instances.
*
* Keeps object identity stable by key while preserving the current
* promotion order and deduplicating duplicate entries by first occurrence.
*/
export class PromotedWidgetViewManager<TView> {
private viewCache = new Map<string, TView>()
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
return this.cachedViews
const views: TView[] = []
const seenKeys = new Set<string>()
for (const entry of entries) {
const key = this.makeKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
seenKeys.add(key)
const existing = this.viewCache.get(key)
if (existing) {
views.push(existing)
continue
}
const nextView = createView(entry)
this.viewCache.set(key, nextView)
views.push(nextView)
}
for (const key of this.viewCache.keys()) {
if (!seenKeys.has(key)) this.viewCache.delete(key)
}
this.cachedViews = views
this.cachedEntryKeys = entryKeys
return views
}
getOrCreate(
sourceNodeId: string,
sourceWidgetName: string,
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
const view = createView()
this.viewCache.set(key, view)
return view
}
remove(sourceNodeId: string, sourceWidgetName: string): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
this.invalidateMemoizedList()
}
removeByViewKey(
sourceNodeId: string,
sourceWidgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
this.invalidateMemoizedList()
}
clear(): void {
this.viewCache.clear()
this.invalidateMemoizedList()
}
invalidateMemoizedList(): void {
this.cachedViews = null
this.cachedEntryKeys = null
}
private areEntryKeysEqual(entryKeys: string[]): boolean {
if (!this.cachedEntryKeys) return false
if (this.cachedEntryKeys.length !== entryKeys.length) return false
for (let index = 0; index < entryKeys.length; index += 1) {
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
}
return true
}
private makeKey(
sourceNodeId: string,
sourceWidgetName: string,
viewKey?: string
): string {
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -486,10 +486,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
'seed',
'seed_1'
])
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
'seed',
'seed_1'
])
}
)
})

View File

@@ -101,43 +101,6 @@ describe('SubgraphNode Memory Management', () => {
})
describe('Widget Promotion Memory Management', () => {
it('should not mutate manually injected widget references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'testInput', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const input = subgraphNode.inputs[0]
const mockWidget = {
type: 'number',
name: 'promoted_widget',
value: 123,
options: {},
y: 0,
draw: vi.fn(),
mouse: vi.fn(),
computeSize: vi.fn(),
createCopyForNode: vi.fn().mockReturnValue({
type: 'number',
name: 'promoted_widget',
value: 123
})
} as Partial<IWidget> as IWidget
input._widget = mockWidget
input.widget = { name: 'promoted_widget' }
subgraphNode.widgets.push(mockWidget)
expect(input._widget).toBe(mockWidget)
expect(input.widget).toBeDefined()
expect(subgraphNode.widgets).toContain(mockWidget)
subgraphNode.removeWidget(mockWidget)
// removeWidget only affects managed promoted widgets, not manually injected entries.
expect(subgraphNode.widgets).toContain(mockWidget)
})
it('should not leak widgets during reconfiguration', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input1', type: 'number' }]

View File

@@ -5,13 +5,19 @@
* IO synchronization, and edge cases.
*/
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
BaseWidget,
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -199,6 +205,148 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('represents promoted host widgets by input widgetId and WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const inputWidgetId = promotedInput.widgetId
expect(subgraphNode.widgets).toMatchObject([
{ name: 'text', widgetId: inputWidgetId }
])
expect(promotedInput._widget).toBe(subgraphNode.widgets[0])
expect(inputWidgetId).toBeDefined()
expect('sourceNodeId' in promotedInput).toBe(false)
expect('sourceWidgetName' in promotedInput).toBe(false)
if (!inputWidgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(inputWidgetId)?.value).toBe(
'initial'
)
})
it('binds promoted host widgets as stable LiteGraph widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widget = subgraphNode.widgets[0]
expect(widget).toBeDefined()
expect(subgraphNode.widgets[0]).toBe(widget)
expect(promotedInput._widget).toBe(widget)
expect(subgraphNode.getWidgetFromSlot(promotedInput)).toBe(widget)
subgraphNode.arrange()
expect(promotedInput.pos?.[1]).toBeGreaterThan(
LiteGraph.NODE_SLOT_HEIGHT * 0.5
)
})
it('does not expose promoted widgetId to BaseWidget assignment', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widget = subgraphNode.widgets[0]
expect(widget?.widgetId).toBeDefined()
expect(() => {
// @ts-expect-error Abstract class instantiation
new BaseWidget({ ...widget, node: subgraphNode })
}).not.toThrow()
})
it('reads promoted widget label and y from WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widget = subgraphNode.widgets[0]
const id = promotedInput.widgetId
if (!id) throw new Error('Missing widgetId')
const state = useWidgetValueStore().getWidget(id)
if (!state) throw new Error('Missing widget state')
state.label = 'Stored Label'
state.y = 27
expect(widget?.name).toBe('text')
expect(widget?.label).toBe('Stored Label')
expect(widget?.y).toBe(27)
})
it('writes promoted widget label and y to WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widget = subgraphNode.widgets[0]
const id = promotedInput.widgetId
if (!id) throw new Error('Missing widgetId')
if (!widget) throw new Error('Missing projected widget')
widget.label = 'Projected Label'
widget.y = 31
expect(useWidgetValueStore().getWidget(id)).toMatchObject({
name: 'text',
label: 'Projected Label',
y: 31
})
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -218,7 +366,6 @@ describe('SubgraphNode Synchronization', () => {
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
@@ -227,17 +374,16 @@ describe('SubgraphNode Synchronization', () => {
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
expect(subgraphNode.widgets).toMatchObject([
{ name: 'text', label: 'my_custom_prompt' }
])
expect(promotedInput.widgetId).toBeDefined()
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
'my_custom_prompt'
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
@@ -254,10 +400,10 @@ describe('SubgraphNode Synchronization', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
const inputSlot = subgraphNode.inputs[0]
expect(inputSlot.widgetId).toBeDefined()
if (!inputSlot.widgetId) throw new Error('Missing widgetId')
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
@@ -267,20 +413,18 @@ describe('SubgraphNode Synchronization', () => {
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
expect(subgraphNode.widgets).toMatchObject([
{ name: 'seed', label: 'My Seed' }
])
expect(inputSlot.label).toBe('My Seed')
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
'My Seed'
)
})
@@ -339,11 +483,10 @@ describe('SubgraphNode widget name collision on rename', () => {
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
expect(subgraphNode.inputs[0].widgetId).not.toBe(
subgraphNode.inputs[1].widgetId
)
})
@@ -394,11 +537,10 @@ describe('SubgraphNode widget name collision on rename', () => {
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
expect(subgraphNode.inputs[0].widgetId).not.toBe(
subgraphNode.inputs[1].widgetId
)
})
@@ -437,17 +579,13 @@ describe('SubgraphNode widget name collision on rename', () => {
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
expect(input.widgetId).toBeDefined()
if (!input.widgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(input.widgetId)).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.inputs).toHaveLength(2)
})
})
@@ -929,41 +1067,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = fromAny<
{
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
},
unknown
>(subgraphNode)
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a:b',
'c'
)
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a',
'b:c'
)
expect(firstKey).not.toBe(secondKey)
})
})
describe('SubgraphNode label propagation', () => {
it('should preserve input labels from configure path', () => {
const subgraph = createTestSubgraph({
@@ -1008,15 +1111,21 @@ describe('SubgraphNode label propagation', () => {
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
expect(promotedInput.label).toBeUndefined()
expect(promotedInput._widget).toBeDefined()
expect(promotedInput._widget).toBe(subgraphNode.widgets[0])
expect(promotedInput.widgetId).toBeDefined()
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
expect(promotedInput.label).toBe('Steps Count')
expect(promotedInput.name).toBe('steps')
expect(promotedInput.widget?.name).toBe(originalWidgetName)
expect(promotedInput._widget?.label).toBe('Steps Count')
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
'Steps Count'
)
expect(subgraphNode.widgets).toMatchObject([
{ name: 'steps', label: 'Steps Count', widgetId: promotedInput.widgetId }
])
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: subgraphNode.id,
slotType: NodeSlotType.INPUT
@@ -1055,3 +1164,81 @@ describe('SubgraphNode label propagation', () => {
expect(subgraphNode.outputs[0].localized_name).toBe('結果')
})
})
describe('SubgraphNode promoted widget control', () => {
function promoteControllableSeed() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'number' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('seed', 'number')
input.widget = { name: 'seed' }
interiorNode.addOutput('out', 'number')
const seed = interiorNode.addWidget('number', 'seed', 1, () => {})
subgraph.add(interiorNode)
useWidgetValueStore().registerWidgetControl(seed.widgetId!, {
mode: 'randomize'
})
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
return { subgraph, subgraphNode: createTestSubgraphNode(subgraph) }
}
it('copies the interior control component onto the host target', () => {
const { subgraphNode } = promoteControllableSeed()
const store = useWidgetValueStore()
const hostId = subgraphNode.inputs[0].widgetId
if (!hostId) throw new Error('Missing widgetId')
expect(subgraphNode.widgets.map((widget) => widget.name)).toEqual(['seed'])
const control = store.getWidgetControl(hostId)
expect(control).toBeDefined()
expect(control!.mode).toBe('randomize')
})
it('falls back to the interior seed value when the host slot is a null hole', () => {
const { subgraph } = promoteControllableSeed()
const store = useWidgetValueStore()
const host = createTestSubgraphNode(subgraph, { id: 16 })
host.configure({
id: 16,
type: subgraph.id,
pos: [0, 0],
size: [210, 210],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {},
widgets_values: [null, 'randomize']
} as ExportedSubgraphInstance)
const hostId = host.inputs[0].widgetId!
expect(store.getWidget(hostId)?.value).toBe(1)
expect(store.getWidgetControl(hostId)?.mode).toBe('randomize')
})
it('round-trips the host control mode through widgets_values', () => {
const { subgraph, subgraphNode } = promoteControllableSeed()
const store = useWidgetValueStore()
const hostId = subgraphNode.inputs[0].widgetId!
store.updateWidgetControl(hostId, { mode: 'fixed' })
const serialized = subgraphNode.serialize()
expect(serialized.properties?.promotedControls).toBeUndefined()
expect(serialized.widgets_values).toEqual([1, 'fixed'])
const reloaded = new SubgraphNode(
subgraph.rootGraph,
subgraph,
serialized as ExportedSubgraphInstance
)
const reloadedControl = store.getWidgetControl(
reloaded.inputs[0].widgetId!
)!
expect(reloadedControl.mode).toBe('fixed')
})
})

View File

@@ -1,3 +1,4 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -33,23 +34,22 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import {
appendControlValues,
applyControlValues
} from '@/core/graph/widgets/control/widgetControl'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { readWidgetValue } from '@/world/widgetValueIO'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
import type { SubgraphInput } from './SubgraphInput'
import { createBitmapCache } from './svgBitmapCache'
@@ -57,11 +57,6 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
slotName: string
}
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
export class SubgraphNode extends LGraphNode implements BaseLGraph {
@@ -85,179 +80,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
}
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
entries: LinkedPromotionEntry[]
}
private _promotedViewsCache?: {
version: number
views: PromotedWidgetView[]
}
declare widgets: IBaseWidget[]
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
const cached = this._linkedEntriesCache
if (cache && cached?.version === this._cacheVersion) return cached.entries
const linkedEntries: LinkedPromotionEntry[] = []
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (boundWidget) {
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
const hasBoundSourceWidget =
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) === true
if (hasBoundSourceWidget) {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
continue
}
}
const resolved =
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
if (!resolved) continue
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
seenEntryKeys.add(entryKey)
return true
})
if (cache)
this._linkedEntriesCache = {
version: this._cacheVersion,
entries: deduplicatedEntries
}
return deduplicatedEntries
}
private _getPromotedViews(): PromotedWidgetView[] {
const cachedViews = this._promotedViewsCache
if (cachedViews?.version === this._cacheVersion) return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const reconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
slotName: string
}> = []
const displayNameByViewKey = new Map<string, string>()
for (const entry of linkedEntries) {
const viewKey = this._makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName
)
reconcileEntries.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
slotName: entry.slotName,
viewKey
})
displayNameByViewKey.set(viewKey, entry.inputName)
}
const views = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
this,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.slotName
)
)
this._promotedViewsCache = {
version: this._cacheVersion,
views
}
return views
}
invalidatePromotedViews(): void {
this._cacheVersion++
}
private _makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = ''
): string {
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
/**
* Retained as a no-op for extension compatibility: promoted host widgets are
* now store-backed and addressed by widgetId, so there is no view cache to
* invalidate.
*/
invalidatePromotedViews(): void {}
private _eventAbortController = new AbortController()
@@ -270,7 +100,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.graph = graph
Object.defineProperty(this, 'widgets', {
get: () => this._getPromotedViews(),
get: () =>
this.inputs.flatMap((input) => {
const widget = this._projectPromotedWidget(input)
return widget ? [widget] : []
}),
set: () => {
if (import.meta.env.DEV)
console.warn(
@@ -307,13 +141,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = inputNode.getWidgetFromSlot(input)
if (widget)
this._setWidget(
subgraphInput,
existingInput,
widget,
input.widget,
inputNode
)
this._setWidget(subgraphInput, existingInput, widget, input.widget)
return
}
const input = this.addInput(name, type, {
@@ -369,8 +197,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
if (input.widgetId) {
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = newName
}
this.invalidatePromotedViews()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -419,6 +250,101 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
private _projectPromotedWidget(
input: INodeInputSlot
): IBaseWidget | undefined {
if (input._widget) return input._widget
const id = input.widgetId
if (!id) return
input._widget = this._createStoreBackedWidget(
id,
() => input.name,
() => input.label ?? input.name
)
return input._widget
}
/**
* Builds a widget whose reads/writes delegate to the store entry for `id`.
*/
private _createStoreBackedWidget(
id: WidgetId,
fallbackName: () => string,
fallbackLabel: () => string
): IBaseWidget {
const store = useWidgetValueStore()
const widget: IBaseWidget = {
get name() {
return store.getWidget(id)?.name ?? fallbackName()
},
get label() {
return store.getWidget(id)?.label ?? fallbackLabel()
},
set label(next) {
const state = store.getWidget(id)
if (state) state.label = next
},
get y() {
return store.getWidget(id)?.y ?? 0
},
set y(next) {
const state = store.getWidget(id)
if (state) state.y = next
},
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
},
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
// so the value setter above is never invoked; BaseWidget.setValue writes
// its own local state and then calls this callback, which is the only
// bridge back to the store.
callback(next) {
store.setValue(id, next)
}
}
Object.defineProperty(widget, 'widgetId', {
value: id,
enumerable: false,
configurable: true
})
return widget
}
/**
* Copies the interior target's control component onto the promoted host
* target. The copy is taken once at promotion; the host control is an
* independent component thereafter.
*/
private _copyPromotedControl(
hostTargetId: WidgetId,
interiorWidget: Readonly<IBaseWidget>
): void {
const store = useWidgetValueStore()
store.deleteWidgetControl(hostTargetId)
const interiorControl = interiorWidget.widgetId
? store.getWidgetControl(interiorWidget.widgetId)
: undefined
if (!interiorControl) return
store.registerWidgetControl(hostTargetId, {
mode: interiorControl.mode,
filter: interiorControl.filter
})
}
private _addSubgraphInputListeners(
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
@@ -437,33 +363,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
this.invalidatePromotedViews()
input.shape = this.getSlotShape(subgraphInput, e.detail.input)
if (!e.detail.widget || !e.detail.node) return
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
const hasStaleBoundWidget =
boundWidget &&
this.subgraph
.getNodeById(boundWidget.sourceNodeId)
?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) !== true
const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget
if (shouldSetWidgetFromEvent)
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget,
e.detail.node
)
this.invalidatePromotedViews()
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget
)
},
{ signal }
)
@@ -482,9 +390,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
if (input._widget) this.ensureWidgetRemoved(input._widget)
if (input.widgetId) useWidgetValueStore().deleteWidget(input.widgetId)
delete input.pos
delete input.widget
delete input.widgetId
input._widget = undefined
this.invalidatePromotedViews()
},
@@ -598,24 +508,38 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
)
super.configure(info)
// Host widget values and control state are restored below in one ordered
// pass; hide them so the base class does not apply them twice.
super.configure({ ...info, widgets_values: undefined })
this._applyPromotedWidgetValues(info.widgets_values)
}
private _applyPromotedWidgetValues(
widgetValues: ExportedSubgraphInstance['widgets_values']
): void {
const quarantineValuesByInputName = this._readQuarantineHostValuesByName()
if (!widgetValues) return
let valueIndex = 0
for (const input of this.inputs) {
const view = input._widget
if (!view || !isPromotedWidgetView(view)) continue
const store = useWidgetValueStore()
const quarantineValuesByInputName = this._readQuarantineHostValuesByName()
const inputNameByTargetId = new Map(
this.inputs.flatMap((input) =>
input.widgetId ? [[input.widgetId, input.name] as const] : []
)
)
let index = 0
for (const widget of this.widgets) {
const id = widget.widgetId
if (!id) continue
if (index >= widgetValues.length) break
const inputName = inputNameByTargetId.get(id)
const value =
quarantineValuesByInputName.get(input.name) ??
widgetValues?.[valueIndex]
if (value !== undefined) view.hydrateHostValue(value)
valueIndex += 1
(inputName !== undefined
? quarantineValuesByInputName.get(inputName)
: undefined) ?? widgetValues[index]
if (value != null) store.setValue(id, value)
index += 1
index = applyControlValues(id, widgetValues, index)
}
}
@@ -639,7 +563,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._rebindInputSubgraphSlots()
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
this._promotedViewManager.clear()
this.invalidatePromotedViews()
this._hydratePreviewExposures()
@@ -683,13 +606,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
rebuildInputWidgetBindings(): void {
this._promotedViewManager.clear()
this.invalidatePromotedViews()
for (const input of this.inputs) {
delete input.widget
delete input.pos
input._widget = undefined
delete input.widgetId
this._clearPromotedWidget(input)
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
this._resolveInputWidget(subgraphInput, input)
@@ -725,76 +648,105 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
if (widget) {
this._setWidget(subgraphInput, input, widget, targetInput.widget)
break
}
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
// Nested promotion: the source is itself a promoted subgraph input with
// no concrete widget. Resolve the deepest interior widget so this input
// still receives widget state and a widgetId.
const nested = this._resolveNestedPromotedSource(inputNode, targetInput)
if (nested) {
this._setWidget(subgraphInput, input, nested.widget, targetInput.widget)
break
}
}
}
private _resolveNestedPromotedSource(
inputNode: LGraphNode,
targetInput: INodeInputSlot
): { node: LGraphNode; widget: IBaseWidget } | undefined {
if (!inputNode.isSubgraphNode()) return undefined
const target = resolveSubgraphInputTarget(inputNode, targetInput.name)
if (!target) return undefined
const resolved = resolveConcretePromotedWidget(
inputNode,
target.nodeId,
target.widgetName
)
return resolved.status === 'resolved' ? resolved.resolved : undefined
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
interiorWidget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
inputWidget: IWidgetLocator | undefined
) {
this.invalidatePromotedViews()
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
this._clearPromotedWidget(input)
const previousView = input._widget
if (
previousView &&
isPromotedWidgetView(previousView) &&
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
this._removePromotedView(previousView)
}
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
() =>
createPromotedWidgetView(
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
)
)
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = view
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
input.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
const hostWidget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)
input._widget = hostWidget
this._copyPromotedControl(id, interiorWidget)
this._setConcreteSlots()
this.subgraph.events.dispatch('widget-promoted', {
widget: view,
widget: interiorWidget,
subgraphNode: this
})
}
/**
* App-layer hook to build a promoted DOM widget; the default falls back to the
* store-backed projection. Litegraph core stays free of Vue/Pinia/DOM.
*/
protected createPromotedHostWidget(
_input: INodeInputSlot,
_id: WidgetId,
_sourceWidget: Readonly<IBaseWidget>
): IBaseWidget | undefined {
return undefined
}
/**
* Runs the host widget's `onRemove` (unregistering DOM widgets) and clears it.
* Unlike {@link ensureWidgetRemoved}, dispatches no demotion event, so it is
* safe on re-resolution.
*/
private _clearPromotedWidget(input: INodeInputSlot): void {
input._widget?.onRemove?.()
input._widget = undefined
if (input.widgetId)
useWidgetValueStore().deleteWidgetControl(input.widgetId)
}
override onAdded(_graph: LGraph): void {
this.invalidatePromotedViews()
}
@@ -816,10 +768,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override getSlotFromWidget(
widget: IBaseWidget | undefined
): INodeInputSlot | undefined {
if (!widget || !isPromotedWidgetView(widget))
return super.getSlotFromWidget(widget)
return this.inputs.find((input) => input._widget === widget)
if (widget?.widgetId) {
const promotedInput = this.inputs.find(
(input) => input.widgetId === widget.widgetId
)
if (promotedInput) return promotedInput
}
return super.getSlotFromWidget(widget)
}
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
@@ -928,83 +883,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return nodes
}
private _clearDomOverrideForView(view: PromotedWidgetView): void {
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName
)
if (resolved.status !== 'resolved') return
const interiorWidget = resolved.resolved.widget
if (
interiorWidget &&
'id' in interiorWidget &&
('element' in interiorWidget || 'component' in interiorWidget)
) {
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
}
}
private _removePromotedView(view: PromotedWidgetView): void {
this.invalidatePromotedViews()
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
for (const input of this.inputs) {
if (input._widget !== view || !input._subgraphSlot) continue
const inputName = input.label ?? input.name
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
)
)
}
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
}
}
widget.onRemove?.()
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
this.invalidatePromotedViews()
}
override onRemoved(): void {
this._eventAbortController.abort()
this.invalidatePromotedViews()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
}
this._promotedViewManager.clear()
for (const input of this.inputs) {
if (
@@ -1013,6 +905,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
) {
input._listenerController.abort()
}
this._clearPromotedWidget(input)
}
}
override drawTitleBox(
@@ -1074,27 +967,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
serialized.properties = serializedProperties
if (
import.meta.env?.DEV &&
this.widgets.some((w) => !isPromotedWidgetView(w))
) {
console.warn(
`SubgraphNode ${this.id}: serialize() drops non-promoted host widgets ` +
`(${this.widgets
.filter((w) => !isPromotedWidgetView(w))
.map((w) => w.name)
.join(', ')}); ` +
'expected only PromotedWidgetView instances.'
)
const store = useWidgetValueStore()
const widgetValues: TWidgetValue[] = []
for (const widget of this.widgets) {
const id = widget.widgetId
const value = id ? store.getWidget(id)?.value : undefined
widgetValues.push(isWidgetValue(value) ? value : undefined)
appendControlValues(id, widgetValues)
}
const widgetValues = this.inputs.flatMap((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return []
const value = readWidgetValue(widget.entityId)
return [isWidgetValue(value) ? value : undefined]
})
if (widgetValues.some((value) => value !== undefined)) {
serialized.widgets_values = widgetValues
} else {

View File

@@ -1,27 +1,27 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ISlotType,
LGraphCanvas,
Subgraph,
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import {
appendQuarantine,
flushProxyWidgetMigration,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
@@ -77,15 +77,46 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
function expectPromotedWidgetView(
widget: unknown
): asserts widget is PromotedWidgetView {
expect(widget).toMatchObject({
sourceNodeId: expect.any(String),
sourceWidgetName: expect.any(String)
function promotedInputs(node: {
inputs: Array<{ widgetId?: WidgetId; name: string }>
}) {
return node.inputs.filter(
(input): input is { widgetId: WidgetId; name: string } =>
Boolean(input.widgetId)
)
}
function promotedWidgetStates(node: {
inputs: Array<{ widgetId?: WidgetId; name: string }>
}) {
return promotedInputs(node).map((input) => {
const state = useWidgetValueStore().getWidget(input.widgetId)
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
return state
})
}
function promotedWidgetStateByName(
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string
) {
const input = node.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const state = useWidgetValueStore().getWidget(input.widgetId)
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
return state
}
function writePromotedWidgetValue(
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
index: number,
value: unknown
) {
const input = promotedInputs(node)[index]
if (!input) throw new Error(`Missing promoted input ${index}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
@@ -101,11 +132,41 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
// The widget should be promoted to the subgraph node
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('value') // Uses subgraph input name
expect(subgraphNode.widgets[0].type).toBe('number')
expect(subgraphNode.widgets[0].value).toBe(42)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(promotedWidgetStateByName(subgraphNode, 'value')).toMatchObject({
type: 'number',
value: 42
})
})
it('resolves nested promoted widgets before the inner host input is hydrated', () => {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: leaf } = createNodeWithWidget('Leaf')
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leaf.inputs[0], leaf)
const innerNode = createTestSubgraphNode(innerSubgraph)
innerNode._internalConfigureAfterSlots()
innerNode.inputs[0].widgetId = undefined
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'outer_value', type: 'number' }]
})
outerSubgraph.add(innerNode)
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph)
outerNode._internalConfigureAfterSlots()
expect(promotedWidgetStateByName(outerNode, 'outer_value')).toMatchObject(
{
type: 'number',
value: 42
}
)
})
it('should promote all widget types', () => {
@@ -147,13 +208,12 @@ describe('SubgraphWidgetPromotion', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
// All widgets should be promoted
expect(subgraphNode.widgets).toHaveLength(3)
// Check specific widget values
expect(subgraphNode.widgets[0].value).toBe(100)
expect(subgraphNode.widgets[1].value).toBe('test')
expect(subgraphNode.widgets[2].value).toBe(true)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(
promotedWidgetStates(subgraphNode).map((state) => state.value)
).toEqual([100, 'test', true])
})
it('should fire widget-promoted event when widget is promoted', () => {
@@ -229,13 +289,13 @@ describe('SubgraphWidgetPromotion', () => {
// Create SubgraphNode
const subgraphNode = createTestSubgraphNode(subgraph)
// Both widgets should be promoted
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].name).toBe('input1')
expect(subgraphNode.widgets[0].value).toBe(10)
expect(subgraphNode.widgets[1].name).toBe('input2')
expect(subgraphNode.widgets[1].value).toBe('hello')
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(promotedWidgetStateByName(subgraphNode, 'input1').value).toBe(10)
expect(promotedWidgetStateByName(subgraphNode, 'input2').value).toBe(
'hello'
)
})
it('should fire widget-demoted events when node is removed', () => {
@@ -246,7 +306,10 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
const eventCapture = createEventCapture(subgraph.events, [
'widget-demoted'
@@ -255,9 +318,9 @@ describe('SubgraphWidgetPromotion', () => {
// Remove the subgraph node
subgraphNode.onRemoved()
// Should fire demoted events for all widgets
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
expect(demotedEvents).toHaveLength(1)
expect(demotedEvents).toHaveLength(0)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
eventCapture.cleanup()
})
@@ -274,7 +337,9 @@ describe('SubgraphWidgetPromotion', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
// No widgets should be promoted
expect(subgraphNode.widgets).toHaveLength(0)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
})
it('should handle disconnection of promoted widget', () => {
@@ -284,13 +349,36 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
// Disconnect the link
subgraph.inputNode.slots[0].disconnect()
// Widget should be removed (through event listeners)
expect(subgraphNode.widgets).toHaveLength(0)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(promotedInputs(subgraphNode)).toHaveLength(0)
})
it('writes canvas edits back to the host widget store', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
// Canvas interaction wraps the projected widget in a transient concrete
// widget (toConcreteWidget), so the edit only reaches the store through
// the widget callback, not the projected widget's value setter.
const hostWidget = subgraphNode.widgets[0]
const concrete = new NumberWidget(fromAny(hostWidget), subgraphNode)
const canvas = fromAny<LGraphCanvas, unknown>({ graph_mouse: [0, 0] })
concrete.setValue(99, { e: fromAny({}), node: subgraphNode, canvas })
expect(promotedWidgetStateByName(subgraphNode, 'value').value).toBe(99)
})
})
@@ -320,10 +408,12 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
expect(hostNode.widgets[0].options.step).toBe(10)
expect(hostNode.widgets).toHaveLength(promotedInputs(hostNode).length)
expect(promotedWidgetStateByName(hostNode, 'batch_size')).toMatchObject({
name: 'batch_size',
value: 1,
options: expect.objectContaining({ step: 10 })
})
})
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
@@ -364,18 +454,17 @@ describe('SubgraphWidgetPromotion', () => {
outerNode.configure(outerNode.serialize())
// Check widgets getter — stale entries should not produce views
const widgetSourceIds = outerNode.widgets
.filter(isPromotedWidgetView)
.filter((w) => !w.name.startsWith('$$'))
.map((w) => w.sourceNodeId)
expect(widgetSourceIds).not.toContain('999')
expect(widgetSourceIds).not.toContain('998')
expect(widgetSourceIds).toContain(keptSamplerNodeId)
expect(outerNode.widgets).toHaveLength(promotedInputs(outerNode).length)
expect(promotedWidgetStateByName(outerNode, 'model').value).toBe(42)
expect(outerNode.properties.proxyWidgets).toEqual([
['999', 'text'],
['998', 'text'],
[keptSamplerNodeId, 'widget']
])
expect(keptSamplerNodeId).toBe(String(samplerNode.id))
})
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
it('resolves legacy prefixed proxyWidgets via the immediate child promoted-widget identity', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -417,12 +506,7 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
flushProxyWidgetMigration({ hostNode })
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0]?.sourceNodeId).toBe(String(nestedNode.id))
expect(hostNode.widgets).toHaveLength(promotedInputs(hostNode).length)
expect(hostNode.properties.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
@@ -446,12 +530,8 @@ describe('SubgraphWidgetPromotion', () => {
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const promotedNames = cloneNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
.map((widget) => widget.sourceWidgetName)
expect(promotedNames).toContain('text')
expect(cloneNode.widgets).toHaveLength(promotedInputs(cloneNode).length)
expect(promotedWidgetStateByName(cloneNode, 'text').value).toBe('')
})
})
@@ -462,6 +542,9 @@ describe('SubgraphWidgetPromotion', () => {
})
const originalTooltip = 'This is a test tooltip'
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget(
'Test Node',
'number',
@@ -471,9 +554,13 @@ describe('SubgraphWidgetPromotion', () => {
)
const subgraphNode = setupPromotedWidget(subgraph, node)
// The promoted widget should preserve the original tooltip
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].tooltip).toBe(originalTooltip)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBe(originalTooltip)
eventCapture.cleanup()
})
it('should handle widgets with no tooltip', () => {
@@ -481,12 +568,19 @@ describe('SubgraphWidgetPromotion', () => {
inputs: [{ name: 'value', type: 'number' }]
})
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget('Test Node', 'number', 42, 'number')
const subgraphNode = setupPromotedWidget(subgraph, node)
// The promoted widget should have undefined tooltip
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBeUndefined()
eventCapture.cleanup()
})
it('should preserve tooltips for multiple promoted widgets', () => {
@@ -539,13 +633,20 @@ describe('SubgraphWidgetPromotion', () => {
multiWidgetNode
)
// Create SubgraphNode
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const subgraphNode = createTestSubgraphNode(subgraph)
// Both widgets should preserve their tooltips
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].tooltip).toBe('Number widget tooltip')
expect(subgraphNode.widgets[1].tooltip).toBe('String widget tooltip')
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(
eventCapture
.getEventsByType('widget-promoted')
.map((event) => event.detail.widget.tooltip)
).toEqual(['Number widget tooltip', 'String widget tooltip'])
eventCapture.cleanup()
})
it('should preserve original tooltip after promotion', () => {
@@ -554,6 +655,9 @@ describe('SubgraphWidgetPromotion', () => {
})
const originalTooltip = 'Original tooltip'
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget(
'Test Node',
'number',
@@ -562,16 +666,16 @@ describe('SubgraphWidgetPromotion', () => {
originalTooltip
)
const subgraphNode = setupPromotedWidget(subgraph, node)
const state = promotedWidgetStateByName(subgraphNode, 'value')
const promotedWidget = subgraphNode.widgets[0]
// The promoted widget should preserve the original tooltip
expect(promotedWidget.tooltip).toBe(originalTooltip)
// The promoted widget should still function normally
expect(promotedWidget.name).toBe('value') // Uses subgraph input name
expect(promotedWidget.type).toBe('number')
expect(promotedWidget.value).toBe(42)
expect(subgraphNode.widgets).toHaveLength(
promotedInputs(subgraphNode).length
)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBe(originalTooltip)
expect(state).toMatchObject({ name: 'value', type: 'number', value: 42 })
eventCapture.cleanup()
})
})
@@ -586,15 +690,7 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
writePromotedWidgetValue(hostNode, 0, 99)
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
@@ -684,28 +780,11 @@ describe('SubgraphWidgetPromotion', () => {
return built
}
function vueEdit(
host: ReturnType<typeof createTestSubgraphNode>,
index: number,
value: EditValue
) {
const widgets = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
widgets[index].updateHandler(value)
}
function applyEdit(
host: ReturnType<typeof createTestSubgraphNode>,
edit: EditSpec
) {
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
else vueEdit(host, edit.index, edit.value)
writePromotedWidgetValue(host, edit.index, edit.value)
}
function applyReorder(
@@ -715,18 +794,14 @@ describe('SubgraphWidgetPromotion', () => {
if (r.kind === 'byName') reorderSubgraphInputsByName(host, r.order)
}
function makeControlWidget(
value: 'increment' | 'fixed',
marker: boolean
) {
const base = {
function makeControlWidget(value: 'increment' | 'fixed') {
return {
name: 'control_after_generate',
value,
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
}
return marker ? { ...base, [IS_CONTROL_WIDGET]: true } : base
}
type ReorderCase = {
@@ -808,7 +883,9 @@ describe('SubgraphWidgetPromotion', () => {
applyReorder(host, c.reorder)
if (c.expectedNames) {
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
expect(promotedInputs(host).map((input) => input.name)).toEqual(
c.expectedNames
)
}
if (c.expectedWidgetsValues !== undefined) {
expect(host.serialize().widgets_values).toEqual(
@@ -830,16 +907,11 @@ describe('SubgraphWidgetPromotion', () => {
name: string
editVia: 'viewKey' | 'vue'
controlMode: 'increment' | 'fixed'
controlMarker: boolean
seedHostValue: number
mutateSourceSeedAfterReorder?: number
callAfterQueued?: boolean
expect: {
promptSeed?: number
sourceSeed?: number
processedSeedValue?: number
hostSeedValue?: number
storeSeedValue?: number
}
}
@@ -848,7 +920,6 @@ describe('SubgraphWidgetPromotion', () => {
name: 'ViewKey + increment: source seed mutation after reorder is ignored in prompt',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: false,
seedHostValue: 123456,
mutateSourceSeedAfterReorder: 789,
expect: { promptSeed: 123456 }
@@ -857,28 +928,8 @@ describe('SubgraphWidgetPromotion', () => {
name: 'Vue + fixed: host-wins — does not push Vue value into source seed',
editVia: 'vue',
controlMode: 'fixed',
controlMarker: false,
seedHostValue: 123456,
expect: { sourceSeed: 0 }
},
{
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
editVia: 'vue',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 123456,
callAfterQueued: true,
expect: { processedSeedValue: 123457 }
},
{
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 2,
mutateSourceSeedAfterReorder: 8,
callAfterQueued: true,
expect: { hostSeedValue: 3, storeSeedValue: 3 }
}
]
@@ -897,26 +948,16 @@ describe('SubgraphWidgetPromotion', () => {
host.graph?.add(host)
}
if (c.editVia === 'viewKey') {
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = c.seedHostValue
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
} else {
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
vueEdit(host, 2, c.seedHostValue)
}
writePromotedWidgetValue(host, 0, 'positive prompt')
writePromotedWidgetValue(host, 1, 'negative prompt')
writePromotedWidgetValue(host, 2, c.seedHostValue)
seed.widget.linkedWidgets = [makeControlWidget(c.controlMode) as never]
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
if (c.mutateSourceSeedAfterReorder !== undefined) {
seed.widget.value = c.mutateSourceSeedAfterReorder
}
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
if (c.expect.promptSeed !== undefined) {
const { output } = await graphToPrompt(host.rootGraph)
@@ -927,53 +968,6 @@ describe('SubgraphWidgetPromotion', () => {
if (c.expect.sourceSeed !== undefined) {
expect(seed.widget.value).toBe(c.expect.sourceSeed)
}
if (c.expect.processedSeedValue !== undefined) {
const updated = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
expect(updated[1].value).toBe(c.expect.processedSeedValue)
}
if (c.expect.hostSeedValue !== undefined) {
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
}
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
it('afterQueued does not run value-control when the host input is externally linked', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
makeControlWidget('increment', true) as never
]
host.widgets[2].value = 2
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
const seedSlot = host.getSlotFromWidget(host.widgets[1])
expect(seedSlot).toBeDefined()
seedSlot!.link = -1
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(2)
})
it('serializes promoted values from each host independently', () => {
@@ -993,8 +987,8 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
firstHost.widgets[0].value = 111
secondHost.widgets[0].value = 222
writePromotedWidgetValue(firstHost, 0, 111)
writePromotedWidgetValue(secondHost, 0, 222)
expect(firstHost.serialize().widgets_values).toEqual([111])
expect(secondHost.serialize().widgets_values).toEqual([222])
@@ -1006,16 +1000,17 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
widgetStore.registerWidget(
widgetId(host.rootGraph.id, node.id, widget.name),
{
type: widget.type,
value: `${node.title} value`,
options: {}
}
)
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
expect(host.serialize().widgets_values).toEqual(['', ''])
})
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
@@ -1032,15 +1027,16 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
widgetStore.registerWidget(
widgetId(host.rootGraph.id, interiorNode.id, interiorWidget.name),
{
type: interiorWidget.type,
value: 'source fallback',
options: {}
}
)
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual([''])
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
@@ -1048,8 +1044,8 @@ describe('SubgraphWidgetPromotion', () => {
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([''])
})
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
@@ -1057,33 +1053,31 @@ describe('SubgraphWidgetPromotion', () => {
buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph, { id: 101 })
host.widgets[1].value = 'second host value'
writePromotedWidgetValue(host, 1, 'second host value')
const serialized = host.serialize()
expect(serialized.widgets_values).toEqual([
undefined,
'second host value'
])
expect(serialized.widgets_values).toEqual(['', 'second host value'])
const widgetStore = useWidgetValueStore()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
const [first, second] = reloaded.widgets
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(reloaded.widgets).toHaveLength(promotedInputs(reloaded).length)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
).toBeUndefined()
widgetStore.getWidget(
widgetId(reloaded.rootGraph.id, reloaded.id, 'first')
)?.value
).toBe('')
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
widgetStore.getWidget(
widgetId(reloaded.rootGraph.id, reloaded.id, 'second')
)?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toHaveLength(1)
).toHaveLength(2)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,
'',
'second host value'
])
})
@@ -1369,7 +1363,12 @@ describe('SubgraphWidgetPromotion', () => {
reloaded.configure(serialized)
const byName = new Map(
reloaded.inputs.map((input) => [input.name, input._widget?.value])
reloaded.inputs.map((input) => [
input.name,
input.widgetId
? useWidgetValueStore().getWidget(input.widgetId)?.value
: undefined
])
)
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')

View File

@@ -1,6 +1,7 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetControlConfig } from '@/types/widgetState'
import type {
CanvasColour,
@@ -399,7 +400,10 @@ export interface IBaseWidget<
linkedWidgets?: IBaseWidget[]
readonly entityId?: WidgetEntityId
/** Transient control intent, registered as a control component on bind. */
controlConfig?: WidgetControlConfig
readonly widgetId?: WidgetId
name: string
options: TOptions

View File

@@ -2,6 +2,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { drawArrowButtons } from './widgetDraw'
/**
* Base class for widgets that have increment and decrement buttons.
@@ -36,26 +37,7 @@ export abstract class BaseSteppedWidget<
* @param width The width of the widget
*/
drawArrowButtons(ctx: CanvasRenderingContext2D, width: number) {
const { height, text_color, disabledTextColor, y } = this
const { arrowMargin, arrowWidth, margin } = BaseWidget
const arrowTipX = margin + arrowMargin
const arrowInnerX = arrowTipX + arrowWidth
// Draw left arrow
ctx.fillStyle = this.canDecrement() ? text_color : disabledTextColor
ctx.beginPath()
ctx.moveTo(arrowInnerX, y + 5)
ctx.lineTo(arrowTipX, y + height * 0.5)
ctx.lineTo(arrowInnerX, y + height - 5)
ctx.fill()
// Draw right arrow
ctx.fillStyle = this.canIncrement() ? text_color : disabledTextColor
ctx.beginPath()
ctx.moveTo(width - arrowInnerX, y + 5)
ctx.lineTo(width - arrowTipX, y + height * 0.5)
ctx.lineTo(width - arrowInnerX, y + height - 5)
ctx.fill()
drawArrowButtons(this, ctx, width, this.canDecrement(), this.canIncrement())
}
override drawWidget(

View File

@@ -7,6 +7,7 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
function createTestWidget(
node: LGraphNode,
@@ -95,9 +96,11 @@ describe('BaseWidget store integration', () => {
widget.disabled = true
widget.advanced = true
const state = store.getWidget(graph.id, 1, 'writeWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'writeWidget'))
expect(state?.label).toBe('Updated Label')
expect(state?.disabled).toBe(true)
expect(state?.hidden).toBe(true)
expect(state?.advanced).toBe(true)
expect(widget.hidden).toBe(true)
expect(widget.advanced).toBe(true)
@@ -108,9 +111,11 @@ describe('BaseWidget store integration', () => {
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
expect(store.getWidget(widgetId(graph.id, 1, 'valueWidget'))?.value).toBe(
99
)
const state = store.getWidget(graph.id, 1, 'valueWidget')!
const state = store.getWidget(widgetId(graph.id, 1, 'valueWidget'))!
state.value = 55
expect(widget.value).toBe(55)
})
@@ -128,14 +133,16 @@ describe('BaseWidget store integration', () => {
})
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'autoRegWidget'))
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.nodeId).toBe('1')
expect(state?.name).toBe('autoRegWidget')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
expect(state?.label).toBe('Auto Label')
expect(state?.disabled).toBe(true)
expect(state?.hidden).toBe(true)
expect(state?.advanced).toBe(true)
expect(state?.options).toEqual({ min: 0, max: 100 })
expect(widget.hidden).toBe(true)
@@ -146,7 +153,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'defaultsWidget'))
expect(state).toBeDefined()
expect(state?.disabled).toBe(false)
expect(state?.label).toBeUndefined()
@@ -159,7 +166,9 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
expect(
store.getWidget(widgetId(graph.id, 1, 'valuesWidget'))?.value
).toBe(77)
})
})
@@ -177,20 +186,26 @@ describe('BaseWidget store integration', () => {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graphId, node.id, 'system_prompt')
)
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graphId, node.id, 'system_prompt')
)
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graph.id, node.id, 'system_prompt')
)
expect(state?.value).toBe(defaultValue)
})
})
@@ -211,8 +226,44 @@ describe('BaseWidget store integration', () => {
widget.disabled = undefined
const state = store.getWidget(graph.id, 1, 'testWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'testWidget'))
expect(state?.disabled).toBe(false)
})
})
describe('layout properties', () => {
it('defaults y to 0 and persists arrange/draw values', () => {
const widget = createTestWidget(node)
expect(widget.y).toBe(0)
widget.y = 30
widget.computedHeight = 20
widget.last_y = 30
widget.computedDisabled = true
expect(widget.y).toBe(30)
expect(widget.computedHeight).toBe(20)
expect(widget.last_y).toBe(30)
expect(widget.computedDisabled).toBe(true)
})
it('exposes y via the `in` operator for extension compatibility', () => {
const widget = createTestWidget(node)
expect('y' in widget).toBe(true)
})
it('keeps clone layout independent from the source', () => {
const widget = createTestWidget(node)
widget.y = 40
widget.computedHeight = 15
const clone = widget.createCopyForNode(node)
expect(clone.y).toBe(40)
expect(clone.computedHeight).toBe(15)
clone.y = 99
expect(widget.y).toBe(40)
})
})
})

View File

@@ -1,7 +1,4 @@
import { t } from '@/i18n'
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
@@ -10,17 +7,28 @@ import type {
LGraphNode,
Size
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { litegraph } from '@/lib/litegraph/src/litegraphInstance'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetState } from '@/stores/widgetValueStore'
import { registerWidgetControlFromConfig } from '@/core/graph/widgets/control/widgetControl'
import { getWidgetLayout } from '@/core/graph/widgets/layout/widgetLayout'
import {
WIDGET_ARROW_MARGIN,
WIDGET_ARROW_WIDTH,
WIDGET_LABEL_VALUE_GAP,
WIDGET_MARGIN,
WIDGET_MIN_VALUE_WIDTH,
drawTruncatingText,
drawWidgetShape
} from '@/lib/litegraph/src/widgets/widgetDraw'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -50,17 +58,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
implements IBaseWidget, NodeBindable
{
/** From node edge to widget edge */
static margin = 15
static margin = WIDGET_MARGIN
/** From widget edge to tip of arrow button */
static arrowMargin = 6
static arrowMargin = WIDGET_ARROW_MARGIN
/** Arrow button width */
static arrowWidth = 10
static arrowWidth = WIDGET_ARROW_WIDTH
/** Absolute minimum display width of widget values */
static minValueWidth = 42
static minValueWidth = WIDGET_MIN_VALUE_WIDTH
/** Minimum gap between label and value */
static labelValueGap = 5
static labelValueGap = WIDGET_LABEL_VALUE_GAP
declare computedHeight?: number
declare serialize?: boolean
computeLayoutSize?(node: LGraphNode): {
minHeight: number
@@ -79,12 +86,41 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
name: string
options: TWidget['options']
type: TWidget['type']
y: number = 0
last_y?: number
width?: number
computedDisabled?: boolean
tooltip?: string
/** Y offset within the node, set during arrange. Backed by a frame-stable layout cache. */
get y(): number {
return getWidgetLayout(this).y
}
set y(value: number) {
getWidgetLayout(this).y = value
}
/** Y offset captured at draw time, read during hit-testing. */
get last_y(): number | undefined {
return getWidgetLayout(this).last_y
}
set last_y(value: number | undefined) {
getWidgetLayout(this).last_y = value
}
/** Height computed during arrange. */
get computedHeight(): number | undefined {
return getWidgetLayout(this).computedHeight
}
set computedHeight(value: number | undefined) {
getWidgetLayout(this).computedHeight = value
}
/** Disabled state derived each draw, read during draw and measurement. */
get computedDisabled(): boolean | undefined {
return getWidgetLayout(this).computedDisabled
}
set computedDisabled(value: boolean | undefined) {
getWidgetLayout(this).computedDisabled = value
}
private _state: Omit<WidgetState, 'nodeId'> &
Partial<Pick<WidgetState, 'nodeId'>>
@@ -95,9 +131,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.label = value
}
hidden?: boolean
advanced?: boolean
get disabled(): boolean | undefined {
return this._state.disabled
}
@@ -105,6 +138,20 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.disabled = value ?? false
}
get hidden(): boolean | undefined {
return this._state.hidden
}
set hidden(value: boolean | undefined) {
this._state.hidden = value
}
get advanced(): boolean | undefined {
return this._state.advanced
}
set advanced(value: boolean | undefined) {
this._state.advanced = value
}
element?: HTMLElement
callback?(
value: TWidget['value'],
@@ -132,11 +179,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.value = value
}
get entityId(): WidgetEntityId | undefined {
get widgetId(): WidgetId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
const nodeId = this._state?.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
return widgetId(graphId, nodeId, this.name)
}
/**
@@ -147,14 +194,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(graphId, {
const id = widgetId(graphId, nodeId, this.name)
this._state = useWidgetValueStore().registerWidget(id, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
value: this.value
})
registerWidgetControlFromConfig(this)
}
constructor(widget: TWidget & { node: LGraphNode })
@@ -194,6 +239,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
labelBaseline,
label,
disabled,
hidden,
advanced,
value,
linkedWidgets,
...safeValues
@@ -207,15 +254,18 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
value,
label,
disabled: disabled ?? false,
hidden,
advanced,
serialize: this.serialize,
options: this.options
options: this.options,
y: this.y
}
}
getOutlineColor() {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
? litegraph().WIDGET_ADVANCED_OUTLINE_COLOR
: litegraph().WIDGET_OUTLINE_COLOR
}
get outline_color() {
@@ -223,23 +273,23 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
get background_color() {
return LiteGraph.WIDGET_BGCOLOR
return litegraph().WIDGET_BGCOLOR
}
get height() {
return LiteGraph.NODE_WIDGET_HEIGHT
return litegraph().NODE_WIDGET_HEIGHT
}
get text_color() {
return LiteGraph.WIDGET_TEXT_COLOR
return litegraph().WIDGET_TEXT_COLOR
}
get secondary_text_color() {
return LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
return litegraph().WIDGET_SECONDARY_TEXT_COLOR
}
get disabledTextColor() {
return LiteGraph.WIDGET_DISABLED_TEXT_COLOR
return litegraph().WIDGET_DISABLED_TEXT_COLOR
}
get displayName() {
@@ -276,23 +326,9 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawWidgetShape(
ctx: CanvasRenderingContext2D,
{ width, showText }: DrawWidgetOptions
options: DrawWidgetOptions
): void {
const { height, y } = this
const { margin } = BaseWidget
ctx.textAlign = 'left'
ctx.strokeStyle = this.getOutlineColor()
ctx.fillStyle = this.background_color
ctx.beginPath()
if (showText) {
ctx.roundRect(margin, y, width - margin * 2, height, [height * 0.5])
} else {
ctx.rect(margin, y, width - margin * 2, height)
}
ctx.fill()
if (showText && !this.computedDisabled) ctx.stroke()
drawWidgetShape(this, ctx, options)
}
/**
@@ -334,66 +370,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
* A shared routine for drawing a label and value as text, truncated
* if they exceed the available width.
*/
protected drawTruncatingText({
ctx,
width,
leftPadding = 5,
rightPadding = 20
}: DrawTruncatingTextOptions): void {
const { height, y } = this
const { margin } = BaseWidget
// Measure label and value
const { displayName, _displayValue } = this
const labelWidth = cachedMeasureText(ctx, displayName)
const valueWidth = cachedMeasureText(ctx, _displayValue)
const gap = BaseWidget.labelValueGap
const x = margin * 2 + leftPadding
const totalWidth = width - x - 2 * margin - rightPadding
const requiredWidth = labelWidth + gap + valueWidth
const area = new Rectangle(x, y, totalWidth, height * 0.7)
ctx.fillStyle = this.secondary_text_color
if (requiredWidth <= totalWidth) {
// Draw label & value normally
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
} else if (LiteGraph.truncateWidgetTextEvenly) {
// Label + value will not fit - scale evenly to fit
const scale = (totalWidth - gap) / (requiredWidth - gap)
area.width = labelWidth * scale
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
// Move the area to the right to render the value
area.right = x + totalWidth
area.setWidthRightAnchored(valueWidth * scale)
} else if (LiteGraph.truncateWidgetValuesFirst) {
// Label + value will not fit - use legacy scaling of value first
const cappedLabelWidth = Math.min(labelWidth, totalWidth)
area.width = cappedLabelWidth
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
area.right = x + totalWidth
area.setWidthRightAnchored(
Math.max(totalWidth - gap - cappedLabelWidth, 0)
)
} else {
// Label + value will not fit - scale label first
const cappedValueWidth = Math.min(valueWidth, totalWidth)
area.width = Math.max(totalWidth - gap - cappedValueWidth, 0)
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
area.right = x + totalWidth
area.setWidthRightAnchored(cappedValueWidth)
}
ctx.fillStyle = this.text_color
drawTextInArea({ ctx, text: _displayValue, area, align: 'right' })
protected drawTruncatingText(options: DrawTruncatingTextOptions): void {
drawTruncatingText(this, options)
}
/**
@@ -447,6 +425,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
// @ts-expect-error - Constructor type casting for widget cloning
const cloned: this = new (this.constructor as typeof this)(this, node)
cloned.value = this.value
cloned.y = this.y
cloned.last_y = this.last_y
cloned.computedHeight = this.computedHeight
cloned.computedDisabled = this.computedDisabled
return cloned
}
}

View File

@@ -1,22 +0,0 @@
import type { IBoundingBoxWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for defining bounding box regions.
* This widget only has a Vue implementation.
*/
export class BoundingBoxWidget
extends BaseWidget<IBoundingBoxWidget>
implements IBoundingBoxWidget
{
override type = 'boundingbox' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'BoundingBox')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IChartWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying charts and data visualizations
* This is a widget that only has a Vue widgets implementation
*/
export class ChartWidget
extends BaseWidget<IChartWidget>
implements IChartWidget
{
override type = 'chart' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Chart: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,16 +0,0 @@
import type { ICurveWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class CurveWidget
extends BaseWidget<ICurveWidget>
implements ICurveWidget
{
override type = 'curve' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Curve')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IFileUploadWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for handling file uploads
* This is a widget that only has a Vue widgets implementation
*/
export class FileUploadWidget
extends BaseWidget<IFileUploadWidget>
implements IFileUploadWidget
{
override type = 'fileupload' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Fileupload: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IGalleriaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying image galleries
* This is a widget that only has a Vue widgets implementation
*/
export class GalleriaWidget
extends BaseWidget<IGalleriaWidget>
implements IGalleriaWidget
{
override type = 'galleria' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Galleria: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IImageCompareWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for comparing two images side by side
* This is a widget that only has a Vue widgets implementation
*/
export class ImageCompareWidget
extends BaseWidget<IImageCompareWidget>
implements IImageCompareWidget
{
override type = 'imagecompare' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `ImageCompare: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,22 +0,0 @@
import type { IImageCropWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying an image crop preview.
* This widget only has a Vue implementation.
*/
export class ImageCropWidget
extends BaseWidget<IImageCropWidget>
implements IImageCropWidget
{
override type = 'imagecrop' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCrop')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IMarkdownWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying markdown formatted text
* This is a widget that only has a Vue widgets implementation
*/
export class MarkdownWidget
extends BaseWidget<IMarkdownWidget>
implements IMarkdownWidget
{
override type = 'markdown' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Markdown: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { IMultiSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for selecting multiple options
* This is a widget that only has a Vue widgets implementation
*/
export class MultiSelectWidget
extends BaseWidget<IMultiSelectWidget>
implements IMultiSelectWidget
{
override type = 'multiselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `MultiSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,22 +0,0 @@
import type { IPainterWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for the Painter node canvas drawing tool.
* This is a widget that only has a Vue widgets implementation.
*/
export class PainterWidget
extends BaseWidget<IPainterWidget>
implements IPainterWidget
{
override type = 'painter' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Painter')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,16 +0,0 @@
import type { IRangeWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class RangeWidget
extends BaseWidget<IRangeWidget>
implements IRangeWidget
{
override type = 'range' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Range')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -1,49 +0,0 @@
import { t } from '@/i18n'
import type { ISelectButtonWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for selecting from a group of buttons
* This is a widget that only has a Vue widgets implementation
*/
export class SelectButtonWidget
extends BaseWidget<ISelectButtonWidget>
implements ISelectButtonWidget
{
override type = 'selectbutton' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `SelectButton: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,22 +0,0 @@
import type { ITextareaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for multi-line text input.
* This widget only has a Vue implementation.
*/
export class TextareaWidget
extends BaseWidget<ITextareaWidget>
implements ITextareaWidget
{
override type = 'textarea' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Textarea')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -1,22 +0,0 @@
import type { ITreeSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for hierarchical tree selection.
* This widget only has a Vue implementation.
*/
export class TreeSelectWidget
extends BaseWidget<ITreeSelectWidget>
implements ITreeSelectWidget
{
override type = 'treeselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'TreeSelect')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -0,0 +1,23 @@
import type { IBaseWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { vueOnlyWidgetBehavior } from './widgetBehavior'
/**
* Placeholder for widgets that only have a Vue implementation. All real
* behavior lives in the Vue node render path; on the classic canvas these draw
* a "Vue only" notice and ignore clicks. A single class covers every Vue-only
* type, delegating to {@link vueOnlyWidgetBehavior} so the behavior is shared
* with the type-keyed behavior registry.
*/
export class VueOnlyWidget<
TWidget extends IBaseWidget = IBaseWidget
> extends BaseWidget<TWidget> {
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
vueOnlyWidgetBehavior.drawWidget(this, ctx, options)
}
onClick(options: WidgetEventOptions): void {
vueOnlyWidgetBehavior.onClick(this, options)
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { DrawWidgetOptions } from './BaseWidget'
import { getWidgetBehavior, vueOnlyWidgetBehavior } from './widgetBehavior'
function createStubContext() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D & {
fillText: ReturnType<typeof vi.fn>
}
}
describe('widget behavior registry', () => {
it('registers Vue-only types and ignores native ones', () => {
expect(getWidgetBehavior('boundingbox')).toBe(vueOnlyWidgetBehavior)
expect(getWidgetBehavior('textarea')).toBe(vueOnlyWidgetBehavior)
expect(getWidgetBehavior('number')).toBeUndefined()
expect(getWidgetBehavior('combo')).toBeUndefined()
})
it('draws the Vue-only placeholder label for the widget type', () => {
const ctx = createStubContext()
const widget = { type: 'boundingbox', y: 10 } as unknown as IBaseWidget
const options: DrawWidgetOptions = { width: 200 }
vueOnlyWidgetBehavior.drawWidget(widget, ctx, options)
expect(ctx.fillText).toHaveBeenCalledTimes(1)
expect(vi.mocked(ctx.fillText).mock.calls[0][0]).toContain('BoundingBox')
})
})

View File

@@ -0,0 +1,79 @@
import { t } from '@/i18n'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { resolveWidgetVisual } from './widgetDraw'
/**
* Canvas behavior for a widget type, expressed as pure functions over widget
* data rather than `this`-bound subclass methods. The seam that lets widgets
* become entities whose behavior lives in systems keyed by `type`.
*/
export interface WidgetBehavior<TWidget extends IBaseWidget = IBaseWidget> {
drawWidget(
widget: TWidget,
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
): void
onClick(widget: TWidget, options: WidgetEventOptions): void
onDrag?(widget: TWidget, options: WidgetEventOptions): void
}
/** Classic-canvas label shown for each Vue-only widget type. */
const vueOnlyLabels: Record<string, string> = {
boundingbox: 'BoundingBox',
chart: 'Chart',
curve: 'Curve',
fileupload: 'Fileupload',
galleria: 'Galleria',
imagecompare: 'ImageCompare',
imagecrop: 'ImageCrop',
markdown: 'Markdown',
multiselect: 'MultiSelect',
painter: 'Painter',
range: 'Range',
selectbutton: 'SelectButton',
textarea: 'Textarea',
treeselect: 'TreeSelect'
}
/**
* Draws the "Vue only" placeholder shown on the classic canvas for widgets that
* only have a Vue implementation.
*/
export const vueOnlyWidgetBehavior: WidgetBehavior = {
drawWidget(widget, ctx, { width }) {
const { y, height, backgroundColor, outlineColor, textColor } =
resolveWidgetVisual(widget)
const label = vueOnlyLabels[widget.type] ?? widget.type
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = outlineColor
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = textColor
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(
`${label}: ${t('widgets.node2only')}`,
width / 2,
y + height / 2
)
ctx.restore()
},
onClick() {}
}
const widgetBehaviors: Record<string, WidgetBehavior> = Object.fromEntries(
Object.keys(vueOnlyLabels).map((type) => [type, vueOnlyWidgetBehavior])
)
/** Returns the registered behavior for a widget type, if any. */
export function getWidgetBehavior(type: string): WidgetBehavior | undefined {
return widgetBehaviors[type]
}

View File

@@ -0,0 +1,176 @@
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { litegraph } from '@/lib/litegraph/src/litegraphInstance'
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
import type { DrawWidgetOptions } from './BaseWidget'
/** From node edge to widget edge. */
export const WIDGET_MARGIN = 15
/** From widget edge to tip of arrow button. */
export const WIDGET_ARROW_MARGIN = 6
/** Arrow button width. */
export const WIDGET_ARROW_WIDTH = 10
/** Absolute minimum display width of widget values. */
export const WIDGET_MIN_VALUE_WIDTH = 42
/** Minimum gap between label and value. */
export const WIDGET_LABEL_VALUE_GAP = 5
/** Widget data read when resolving how a widget should look. */
interface WidgetVisualData {
y: number
advanced?: boolean
computedDisabled?: boolean
}
/** Widget data read when drawing label/value text. */
interface WidgetTextData extends WidgetVisualData {
displayName: string
_displayValue: string
}
function outlineColor(widget: Pick<WidgetVisualData, 'advanced'>): string {
const theme = litegraph()
return widget.advanced
? theme.WIDGET_ADVANCED_OUTLINE_COLOR
: theme.WIDGET_OUTLINE_COLOR
}
/** Visual values needed to draw a widget, resolved from widget data + theme. */
export function resolveWidgetVisual(widget: WidgetVisualData) {
const theme = litegraph()
return {
y: widget.y,
height: theme.NODE_WIDGET_HEIGHT,
backgroundColor: theme.WIDGET_BGCOLOR,
outlineColor: outlineColor(widget),
textColor: theme.WIDGET_TEXT_COLOR
}
}
/**
* Draws the standard widget shape - elongated capsule. The path is not cleared,
* and may be used for further drawing.
* @remarks Leaves `ctx` dirty.
*/
export function drawWidgetShape(
widget: WidgetVisualData,
ctx: CanvasRenderingContext2D,
{ width, showText }: DrawWidgetOptions
): void {
const { y } = widget
const height = litegraph().NODE_WIDGET_HEIGHT
ctx.textAlign = 'left'
ctx.strokeStyle = outlineColor(widget)
ctx.fillStyle = litegraph().WIDGET_BGCOLOR
ctx.beginPath()
if (showText) {
ctx.roundRect(WIDGET_MARGIN, y, width - WIDGET_MARGIN * 2, height, [
height * 0.5
])
} else {
ctx.rect(WIDGET_MARGIN, y, width - WIDGET_MARGIN * 2, height)
}
ctx.fill()
if (showText && !widget.computedDisabled) ctx.stroke()
}
interface DrawTruncatingTextOptions {
ctx: CanvasRenderingContext2D
width: number
leftPadding?: number
rightPadding?: number
}
/**
* Draws a label and value as text, truncated if they exceed the available
* width.
*/
export function drawTruncatingText(
widget: WidgetTextData,
{ ctx, width, leftPadding = 5, rightPadding = 20 }: DrawTruncatingTextOptions
): void {
const theme = litegraph()
const { y } = widget
const height = theme.NODE_WIDGET_HEIGHT
const { displayName, _displayValue } = widget
const labelWidth = cachedMeasureText(ctx, displayName)
const valueWidth = cachedMeasureText(ctx, _displayValue)
const gap = WIDGET_LABEL_VALUE_GAP
const x = WIDGET_MARGIN * 2 + leftPadding
const totalWidth = width - x - 2 * WIDGET_MARGIN - rightPadding
const requiredWidth = labelWidth + gap + valueWidth
const area = new Rectangle(x, y, totalWidth, height * 0.7)
ctx.fillStyle = theme.WIDGET_SECONDARY_TEXT_COLOR
if (requiredWidth <= totalWidth) {
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
} else if (theme.truncateWidgetTextEvenly) {
const scale = (totalWidth - gap) / (requiredWidth - gap)
area.width = labelWidth * scale
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
area.right = x + totalWidth
area.setWidthRightAnchored(valueWidth * scale)
} else if (theme.truncateWidgetValuesFirst) {
const cappedLabelWidth = Math.min(labelWidth, totalWidth)
area.width = cappedLabelWidth
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
area.right = x + totalWidth
area.setWidthRightAnchored(Math.max(totalWidth - gap - cappedLabelWidth, 0))
} else {
const cappedValueWidth = Math.min(valueWidth, totalWidth)
area.width = Math.max(totalWidth - gap - cappedValueWidth, 0)
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
area.right = x + totalWidth
area.setWidthRightAnchored(cappedValueWidth)
}
ctx.fillStyle = theme.WIDGET_TEXT_COLOR
drawTextInArea({ ctx, text: _displayValue, area, align: 'right' })
}
/**
* Draws the increment/decrement arrow buttons for stepped widgets. The caller
* supplies whether each direction is currently enabled.
*/
export function drawArrowButtons(
widget: Pick<WidgetVisualData, 'y'>,
ctx: CanvasRenderingContext2D,
width: number,
canDecrement: boolean,
canIncrement: boolean
): void {
const theme = litegraph()
const { y } = widget
const height = theme.NODE_WIDGET_HEIGHT
const textColor = theme.WIDGET_TEXT_COLOR
const disabledTextColor = theme.WIDGET_DISABLED_TEXT_COLOR
const arrowTipX = WIDGET_MARGIN + WIDGET_ARROW_MARGIN
const arrowInnerX = arrowTipX + WIDGET_ARROW_WIDTH
ctx.fillStyle = canDecrement ? textColor : disabledTextColor
ctx.beginPath()
ctx.moveTo(arrowInnerX, y + 5)
ctx.lineTo(arrowTipX, y + height * 0.5)
ctx.lineTo(arrowInnerX, y + height - 5)
ctx.fill()
ctx.fillStyle = canIncrement ? textColor : disabledTextColor
ctx.beginPath()
ctx.moveTo(width - arrowInnerX, y + 5)
ctx.lineTo(width - arrowTipX, y + height * 0.5)
ctx.lineTo(width - arrowInnerX, y + height - 5)
ctx.fill()
}

View File

@@ -2,7 +2,21 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IAssetWidget,
IBaseWidget,
IBoundingBoxWidget,
IChartWidget,
IComboWidget,
ICurveWidget,
IFileUploadWidget,
IGalleriaWidget,
IImageCompareWidget,
IImageCropWidget,
IMarkdownWidget,
IMultiSelectWidget,
IPainterWidget,
IRangeWidget,
ISelectButtonWidget,
ITextareaWidget,
ITreeSelectWidget,
IWidget,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
@@ -11,29 +25,16 @@ import { toClass } from '@/lib/litegraph/src/utils/type'
import { AssetWidget } from './AssetWidget'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { BoundingBoxWidget } from './BoundingBoxWidget'
import { ButtonWidget } from './ButtonWidget'
import { ChartWidget } from './ChartWidget'
import { ColorWidget } from './ColorWidget'
import { ComboWidget } from './ComboWidget'
import { CurveWidget } from './CurveWidget'
import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
import { MarkdownWidget } from './MarkdownWidget'
import { MultiSelectWidget } from './MultiSelectWidget'
import { NumberWidget } from './NumberWidget'
import { SelectButtonWidget } from './SelectButtonWidget'
import { SliderWidget } from './SliderWidget'
import { TextWidget } from './TextWidget'
import { TextareaWidget } from './TextareaWidget'
import { TreeSelectWidget } from './TreeSelectWidget'
import { VueOnlyWidget } from './VueOnlyWidget'
export type WidgetTypeMap = {
button: ButtonWidget
@@ -46,22 +47,22 @@ export type WidgetTypeMap = {
string: TextWidget
text: TextWidget
custom: LegacyWidget
fileupload: FileUploadWidget
fileupload: VueOnlyWidget<IFileUploadWidget>
color: ColorWidget
markdown: MarkdownWidget
treeselect: TreeSelectWidget
multiselect: MultiSelectWidget
chart: ChartWidget
galleria: GalleriaWidget
imagecompare: ImageCompareWidget
selectbutton: SelectButtonWidget
textarea: TextareaWidget
markdown: VueOnlyWidget<IMarkdownWidget>
treeselect: VueOnlyWidget<ITreeSelectWidget>
multiselect: VueOnlyWidget<IMultiSelectWidget>
chart: VueOnlyWidget<IChartWidget>
galleria: VueOnlyWidget<IGalleriaWidget>
imagecompare: VueOnlyWidget<IImageCompareWidget>
selectbutton: VueOnlyWidget<ISelectButtonWidget>
textarea: VueOnlyWidget<ITextareaWidget>
asset: AssetWidget
imagecrop: ImageCropWidget
boundingbox: BoundingBoxWidget
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
imagecrop: VueOnlyWidget<IImageCropWidget>
boundingbox: VueOnlyWidget<IBoundingBoxWidget>
curve: VueOnlyWidget<ICurveWidget>
painter: VueOnlyWidget<IPainterWidget>
range: VueOnlyWidget<IRangeWidget>
[key: string]: BaseWidget
}
@@ -112,38 +113,25 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(TextWidget, narrowedWidget, node)
case 'text':
return toClass(TextWidget, narrowedWidget, node)
case 'fileupload':
return toClass(FileUploadWidget, narrowedWidget, node)
case 'color':
return toClass(ColorWidget, narrowedWidget, node)
case 'markdown':
return toClass(MarkdownWidget, narrowedWidget, node)
case 'treeselect':
return toClass(TreeSelectWidget, narrowedWidget, node)
case 'multiselect':
return toClass(MultiSelectWidget, narrowedWidget, node)
case 'chart':
return toClass(ChartWidget, narrowedWidget, node)
case 'galleria':
return toClass(GalleriaWidget, narrowedWidget, node)
case 'imagecompare':
return toClass(ImageCompareWidget, narrowedWidget, node)
case 'selectbutton':
return toClass(SelectButtonWidget, narrowedWidget, node)
case 'textarea':
return toClass(TextareaWidget, narrowedWidget, node)
case 'asset':
return toClass(AssetWidget, narrowedWidget, node)
case 'fileupload':
case 'markdown':
case 'treeselect':
case 'multiselect':
case 'chart':
case 'galleria':
case 'imagecompare':
case 'selectbutton':
case 'textarea':
case 'imagecrop':
return toClass(ImageCropWidget, narrowedWidget, node)
case 'boundingbox':
return toClass(BoundingBoxWidget, narrowedWidget, node)
case 'curve':
return toClass(CurveWidget, narrowedWidget, node)
case 'painter':
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
return toClass(VueOnlyWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -4,11 +4,11 @@ import { computed } from 'vue'
import type { LinearInput } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { cn } from '@comfyorg/tailwind-utils'
const { entityId, name } = defineProps<{
entityId?: WidgetEntityId
const { widgetId, name } = defineProps<{
widgetId?: WidgetId
enable: boolean
name: string
}>()
@@ -17,12 +17,12 @@ const appModeStore = useAppModeStore()
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
function matchesThis([storedId]: LinearInput) {
return entityId !== undefined && storedId === entityId
return widgetId !== undefined && storedId === widgetId
}
function togglePromotion() {
if (!entityId) return
if (!widgetId) return
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
else appModeStore.selectedInputs.push([entityId, name])
else appModeStore.selectedInputs.push([widgetId, name])
}
</script>
<template>

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