Compare commits

...

38 Commits

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

Remove redundant SubgraphNode & properties intersection type

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

- navigateIntoSubgraph clicks title button instead of dblclick on body

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

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

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

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

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

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

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

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

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

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

Delete promotionList.ts and its test file.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- PromotedWidgetSlot.promoted returns false

- PromotedDomWidgetAdapter.promoted returns false

- Suppress promoted border during delegated draw to concrete widget

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

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

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

the concrete widget before delegating the draw call.

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

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

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

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

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

- Sync interior node input label in PromotedWidgetSlot.label setter

- Extract syncPromotedWidgets from side-effecting property setter

- Add idempotency guard to registerPromotedWidgetSlots

- Add tests for PromotedWidgetSlot, promotedWidgetRegistration, widgetUtil

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

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

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

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

but PromotedWidgetSlot hardcoded options: {} in the constructor.

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

Add resolvedOptions getter via defineProperty (same pattern as type)

and delete the own property set by BaseWidget constructor.

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

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

- Delete proxyWidget.test.ts

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

- Clean JSDoc reference in promotedWidgetRegistration.ts

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

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

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

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

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

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

- WidgetActions: handleHideInput calls demoteWidget directly

- SectionWidgets: isWidgetShownOnParents uses widgetNode.id matching

- TabSubgraphInputs: resolves interior nodes from subgraph

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

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

- safeWidgetMapper() no longer special-cases ProxyWidgets

- extractVueNodeData() resolves promoted widgets via getPromotionList + resolvePromotedWidget

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

- NodeWidgets.vue unchanged (already reads from widgetValueStore)

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

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

Amp-Thread-ID: https://ampcode.com/threads/T-019c4fd4-6e4c-721f-8106-7b3f3cb93990
Co-authored-by: Amp <amp@ampcode.com>
2026-02-12 20:03:29 -08:00
42 changed files with 2002 additions and 725 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,16 @@ import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
getSharedWidgetEnhancements,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -74,3 +82,36 @@ describe('Node Reactivity', () => {
expect(widgetValue.value).toBe(99)
})
})
describe('getSharedWidgetEnhancements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns nodeType when sourceNodeId is provided for a subgraph node', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('KSampler', 'KSampler')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(
subgraphNode,
widget,
String(interiorNode.id)
)
expect(result.nodeType).toBe('KSampler')
})
it('returns undefined nodeType when sourceNodeId is omitted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const widget = { name: 'seed', type: 'number', value: 0 } as IBaseWidget
const result = getSharedWidgetEnhancements(subgraphNode, widget)
expect(result.nodeType).toBeUndefined()
})
})

View File

@@ -6,7 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -14,7 +14,6 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -27,7 +26,8 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
NodeId
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -121,9 +121,9 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
}
}
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
function getNodeType(node: LGraphNode, sourceNodeId?: NodeId) {
if (!node.isSubgraphNode() || !sourceNodeId) return undefined
const subNode = node.subgraph.getNodeById(sourceNodeId)
return subNode?.type
}
@@ -146,14 +146,15 @@ interface SharedWidgetEnhancements {
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
widget: IBaseWidget,
sourceNodeId?: NodeId
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget)
nodeType: getNodeType(node, sourceNodeId)
}
}
@@ -195,7 +196,17 @@ function safeWidgetMapper(
return function (widget) {
try {
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const nodeId =
'sourceNodeId' in widget ? String(widget.sourceNodeId) : node.id
const widgetName =
'sourceWidgetName' in widget
? String(widget.sourceWidgetName)
: widget.name
const sharedEnhancements = getSharedWidgetEnhancements(
node,
widget,
nodeId
)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
@@ -219,20 +230,10 @@ function safeWidgetMapper(
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name
return {
nodeId,
name,
name: widgetName,
type: widget.type,
...sharedEnhancements,
callback,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -7,6 +7,7 @@ import type { LGraphEventMap } from './LGraphEventMap'
export interface SubgraphInputEventMap extends LGraphEventMap {
'input-connected': {
input: INodeInputSlot
node: LGraphNode
widget: IBaseWidget
}

View File

@@ -90,6 +90,7 @@ export class SubgraphInput extends SubgraphSlot {
this._widget ??= inputWidget
this.events.dispatch('input-connected', {
input: slot,
node,
widget: inputWidget
})
}

View File

@@ -29,8 +29,6 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -93,8 +91,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget)
this._setWidget(subgraphInput, existingInput, widget, input?.widget)
if (widget && inputNode)
this._setWidget(
subgraphInput,
existingInput,
widget,
input?.widget,
inputNode
)
return
}
const input = this.addInput(name, type)
@@ -108,7 +112,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
'removing-input',
(e) => {
const widget = e.detail.input._widget
if (widget) this.ensureWidgetRemoved(widget)
if (widget && this.widgets.includes(widget))
this.ensureWidgetRemoved(widget)
this.removeInput(e.detail.index)
this.setDirtyCanvas(true, true)
@@ -205,8 +210,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = subgraphInput._widget
if (!widget) return
const widgetLocator = e.detail.input.widget
this._setWidget(subgraphInput, input, widget, widgetLocator)
this._setWidget(
subgraphInput,
input,
widget,
e.detail.input.widget,
e.detail.node
)
},
{ signal }
)
@@ -218,7 +228,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const connectedWidgets = subgraphInput.getConnectedWidgets()
if (connectedWidgets.length > 0) return
this.removeWidgetByName(input.name)
if (input._widget) this.ensureWidgetRemoved(input._widget)
delete input.pos
delete input.widget
@@ -323,7 +333,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(subgraphInput, input, widget, targetInput.widget)
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
}
@@ -333,66 +349,52 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
// Use the first matching widget
const promotedWidget =
widget instanceof BaseWidget
? widget.createCopyForNode(this)
: { ...widget, node: this }
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
const sourceWidget = widget as IBaseWidget
const stub: IBaseWidget = Object.create(null)
Object.assign(promotedWidget, {
get name() {
return subgraphInput.name
Object.defineProperties(stub, {
sourceNodeId: { value: String(interiorNode.id), enumerable: true },
sourceWidgetName: { value: sourceWidget.name, enumerable: true },
node: { value: this, enumerable: true },
name: {
get: () => subgraphInput.name,
enumerable: true
},
set name(value) {
console.warn(
'Promoted widget: setting name is not allowed',
this,
value
)
type: {
get: () => sourceWidget.type,
enumerable: true
},
get localized_name() {
return subgraphInput.localized_name
value: {
get: () => sourceWidget.value,
set: (v) => {
sourceWidget.value = v
},
enumerable: true
},
set localized_name(value) {
console.warn(
'Promoted widget: setting localized_name is not allowed',
this,
value
)
options: {
get: () => sourceWidget.options,
enumerable: true
},
get label() {
return subgraphInput.label
label: {
get: () => subgraphInput.label,
set() {},
enumerable: true
},
set label(value) {
console.warn(
'Promoted widget: setting label is not allowed',
this,
value
)
},
get tooltip() {
// Preserve the original widget's tooltip for promoted widgets
return widget.tooltip
},
set tooltip(value) {
console.warn(
'Promoted widget: setting tooltip is not allowed',
this,
value
)
tooltip: {
get: () => widget.tooltip,
enumerable: true
}
})
const widgetCount = this.inputs.filter((i) => i.widget).length
this.widgets.splice(widgetCount, 0, promotedWidget)
this.widgets.splice(widgetCount, 0, stub)
// Dispatch widget-promoted event
this.subgraph.events.dispatch('widget-promoted', {
widget: promotedWidget,
widget: stub,
subgraphNode: this
})
@@ -403,7 +405,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = promotedWidget
input._widget = stub
// Trigger promoted widget slot sync for live connections.
// During configure, refreshPromotedWidgets is a no-op.
// After configure, it triggers syncPromotedWidgets which
// replaces this stub with a proper PromotedWidgetSlot.
this.refreshPromotedWidgets()
}
/**
@@ -546,6 +554,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
super.removeWidgetByName(name)
}
/**
* Hook for the promoted widget system to sync promoted widget slots.
* Overridden per-instance by promotedWidgetRegistration.ts after configure.
* @internal
*/
refreshPromotedWidgets(): void {
// No-op by default. Overridden by promotedWidgetRegistration.
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (this.widgets.includes(widget)) {
this.subgraph.events.dispatch('widget-demoted', {
@@ -560,13 +577,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Clean up all subgraph event listeners
this._eventAbortController.abort()
// Clean up all promoted widgets
// Dispatch widget-demoted for all widgets so listeners can clean up
for (const widget of this.widgets) {
if ('isProxyWidget' in widget && widget.isProxyWidget) continue
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
// Dispose promoted widget slots (cleans up DOM adapters)
if ('dispose' in widget && typeof widget.dispose === 'function') {
widget.dispose()
}
}
for (const input of this.inputs) {
@@ -637,9 +657,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override clone() {
const clone = super.clone()
// force reasign so domWidgets reset ownership
this.properties.proxyWidgets = this.properties.proxyWidgets
// Sync promoted widgets so DOM adapters reset ownership
this.refreshPromotedWidgets()
//TODO: Consider deep cloning subgraphs here.
//It's the safest place to prevent creation of linked subgraphs

View File

@@ -372,10 +372,13 @@ export interface IBaseWidget<
* This property is automatically computed on graph change
* and should not be changed.
* Promoted widgets have a colored border
* @see /core/graph/subgraph/proxyWidget.registerProxyWidgets
* @see /core/graph/subgraph/promotedWidgetRegistration.registerPromotedWidgetSlots
*/
promoted?: boolean
/** Whether this widget is a PromotedWidgetSlot (a lightweight proxy for an interior widget). */
isPromotedSlot?: boolean
tooltip?: string
// TODO: Confirm this format

View File

@@ -158,7 +158,7 @@ describe('BaseWidget store integration', () => {
expect(state?.promoted).toBe(false)
expect(state?.label).toBeUndefined()
expect(widget.hidden).toBeUndefined()
expect(widget.hidden).toBe(false)
expect(widget.advanced).toBeUndefined()
})

View File

@@ -55,6 +55,9 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
/** Minimum gap between label and value */
static labelValueGap = 5
/** Whether this widget is a promoted slot (overridden in PromotedWidgetSlot). */
readonly isPromotedSlot: boolean = false
declare computedHeight?: number
declare serialize?: boolean
computeLayoutSize?(node: LGraphNode): {
@@ -90,7 +93,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.label = value
}
hidden?: boolean
get hidden(): boolean | undefined {
return this._state.hidden
}
set hidden(value: boolean | undefined) {
this._state.hidden = value ?? false
}
advanced?: boolean
get disabled(): boolean | undefined {
@@ -179,6 +188,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
displayValue,
// @ts-expect-error Prevent naming conflicts with custom nodes.
labelBaseline,
hidden,
label,
disabled,
promoted,
@@ -193,6 +203,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
name: this.name,
type: this.type as TWidgetType,
value,
hidden: hidden ?? false,
label,
disabled: disabled ?? false,
promoted: promoted ?? false,

View File

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

View File

@@ -234,14 +234,14 @@ class LayoutStoreImpl implements LayoutStore {
return {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
const layout = ynode ? yNodeToLayout(ynode) : null
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(String(nodeId))
if (existing) {
this.applyOperation({
type: 'deleteNode',
@@ -255,7 +255,7 @@ class LayoutStoreImpl implements LayoutStore {
}
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(String(nodeId))
if (!existing) {
// Create operation
this.applyOperation({
@@ -685,7 +685,7 @@ class LayoutStoreImpl implements LayoutStore {
// Precise hit test only on candidates
for (const key of candidateKeys) {
const segmentLayout = this.linkSegmentLayouts.get(key)
const segmentLayout = this.linkSegmentLayouts.get(String(key))
if (!segmentLayout) continue
if (ctx && segmentLayout.path) {
@@ -748,7 +748,7 @@ class LayoutStoreImpl implements LayoutStore {
// Check precise bounds for candidates
for (const key of candidateSlotKeys) {
const slotLayout = this.slotLayouts.get(key)
const slotLayout = this.slotLayouts.get(String(key))
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
return slotLayout
}
@@ -812,7 +812,7 @@ class LayoutStoreImpl implements LayoutStore {
const segmentKeys = this.linkSegmentSpatialIndex.query(bounds)
const linkIds = new Set<LinkId>()
for (const key of segmentKeys) {
const segment = this.linkSegmentLayouts.get(key)
const segment = this.linkSegmentLayouts.get(String(key))
if (segment) {
linkIds.add(segment.linkId)
}
@@ -821,7 +821,7 @@ class LayoutStoreImpl implements LayoutStore {
return {
nodes: this.queryNodesInBounds(bounds),
links: Array.from(linkIds),
slots: this.slotSpatialIndex.query(bounds),
slots: this.slotSpatialIndex.query(bounds).map(String),
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => asRerouteId(key))
@@ -1002,7 +1002,7 @@ class LayoutStoreImpl implements LayoutStore {
}
}
this.ynodes.set(layout.id, layoutToYNode(layout))
this.ynodes.set(String(layout.id), layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
@@ -1018,7 +1018,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) {
return
}
@@ -1046,7 +1046,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
const position = yNodeToLayout(ynode).position
@@ -1072,7 +1072,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
@@ -1084,7 +1084,7 @@ class LayoutStoreImpl implements LayoutStore {
change: LayoutChange
): void {
const ynode = layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
this.ynodes.set(String(operation.nodeId), ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
@@ -1097,9 +1097,9 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteNodeOperation,
change: LayoutChange
): void {
if (!this.ynodes.has(operation.nodeId)) return
if (!this.ynodes.has(String(operation.nodeId))) return
this.ynodes.delete(operation.nodeId)
this.ynodes.delete(String(operation.nodeId))
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
// During undo/redo, Vue components may still hold references to the old ref.
// If we delete the trigger, Vue won't be notified when the node is re-created.
@@ -1132,7 +1132,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
@@ -1440,7 +1440,7 @@ class LayoutStoreImpl implements LayoutStore {
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)

View File

@@ -32,7 +32,7 @@ export function useLayoutSync() {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
const liteNode = canvas.graph?.getNodeById(nodeId)
if (!liteNode) continue
if (

View File

@@ -4,8 +4,11 @@
* This file contains all type definitions for the layout system
* that manages node positions, bounds, spatial data, and operations.
*/
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ComputedRef, Ref } from 'vue'
export type { NodeId }
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',
@@ -37,7 +40,6 @@ export interface NodeBoundsUpdate {
bounds: Bounds
}
export type NodeId = string
export type LinkId = number
export type RerouteId = number

View File

@@ -43,7 +43,7 @@ export class SpatialIndexManager {
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.quadTree.insert(String(nodeId), bounds, nodeId)
this.invalidateCache()
}
@@ -51,7 +51,7 @@ export class SpatialIndexManager {
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.quadTree.update(String(nodeId), bounds)
this.invalidateCache()
}
@@ -61,7 +61,7 @@ export class SpatialIndexManager {
*/
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
for (const { nodeId, bounds } of updates) {
this.quadTree.update(nodeId, bounds)
this.quadTree.update(String(nodeId), bounds)
}
this.invalidateCache()
}
@@ -70,7 +70,7 @@ export class SpatialIndexManager {
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.quadTree.remove(String(nodeId))
this.invalidateCache()
}

View File

@@ -233,6 +233,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
@@ -283,11 +284,11 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
useVueElementTracking(() => String(nodeData.id), 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
return selectedNodeIds.value.has(String(nodeData.id))
})
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
@@ -343,8 +344,10 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => String(nodeData.id))
const { pointerHandlers } = useNodePointerInteractions(() =>
String(nodeData.id)
)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
@@ -397,7 +400,7 @@ function initSizeStyles() {
*/
function handleLayoutChange(change: {
source: LayoutSource
nodeIds: string[]
nodeIds: NodeId[]
}) {
// Only handle Canvas or External source (extensions calling setSize)
if (
@@ -487,7 +490,7 @@ const hasCustomContent = computed(() => {
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id,
() => String(nodeData.id),
{
isCollapsed
}

View File

@@ -236,7 +236,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
hidden: widgetState?.hidden ?? widget.options?.hidden ?? false,
name: widget.name,
type: widget.type,
vueComponent,

View File

@@ -32,7 +32,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
const multiSelect = isMultiSelectKey(event)
@@ -69,7 +69,7 @@ function useNodeEventHandlersIndividual() {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
@@ -88,7 +88,7 @@ function useNodeEventHandlersIndividual() {
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Update the node title in LiteGraph for persistence
@@ -104,7 +104,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
// Prevent default context menu
@@ -127,7 +127,7 @@ function useNodeEventHandlersIndividual() {
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
const node = nodeManager.value.getNode(String(nodeId))
if (!node) return
if (!multiSelect) {

View File

@@ -355,7 +355,7 @@ export function useSlotLinkInteraction({
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
String(slotCandidate.layout.nodeId),
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
@@ -363,7 +363,7 @@ export function useSlotLinkInteraction({
}
if (nodeCandidate && !slotCandidate?.compatible) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
String(nodeCandidate.layout.nodeId),
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
@@ -374,7 +374,7 @@ export function useSlotLinkInteraction({
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
String(newCandidate.layout.nodeId),
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)

View File

@@ -58,7 +58,7 @@ function useNodeDragIndividual() {
// capture the starting positions of all other selected nodes
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
const isDraggedNodeInSelection = selectedNodes?.has(String(nodeId))
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()

View File

@@ -7,7 +7,7 @@ import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { registerPromotedWidgetSlots } from '@/core/graph/subgraph/promotedWidgetRegistration'
import { st, t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import {
@@ -849,6 +849,8 @@ export class ComfyApp {
.map((w) => [w.id, w])
)
const newGraphNodeSet = new Set(newGraph.nodes)
for (const [
widgetId,
widgetState
@@ -856,6 +858,10 @@ export class ComfyApp {
if (widgetId in activeWidgets) {
widgetState.active = true
widgetState.widget = activeWidgets[widgetId]
} else if (newGraphNodeSet.has(widgetState.widget.node)) {
// Adapter widgets (e.g. PromotedDomWidgetAdapter) are not in any
// node's widgets array but their host node IS in the graph.
widgetState.active = true
} else {
widgetState.active = false
}
@@ -879,7 +885,7 @@ export class ComfyApp {
}
)
registerProxyWidgets(this.canvas)
registerPromotedWidgetSlots(this.canvas)
this.rootGraph.start()

View File

@@ -30,11 +30,12 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
widget: BaseDOMWidget<V>,
options?: { visible?: boolean }
) => {
widgetStates.value.set(widget.id, {
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
visible: true,
visible: options?.visible ?? true,
readonly: false,
zIndex: 0,
pos: [0, 0],

View File

@@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
import { computed, ref, watchEffect } from 'vue'
import { t } from '@/i18n'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
@@ -397,18 +397,29 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
if (!node.isSubgraphNode()) {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
}
// For subgraph nodes, find the interior node via the promotion list
const entry = parseProxyWidgets(node.properties.proxyWidgets).find(
([, name]) => name === widgetName
)
if (entry) {
const [nodeId] = entry
const subNode = node.subgraph.getNodeById(nodeId)
if (!subNode) return undefined
return getInputSpecForWidget(subNode, widgetName)
}
// Also check slot-promoted widgets (System 1) in node.widgets
const widget = node.widgets?.find((w) => w.name === widgetName)
//TODO: resolve spec for linked
if (!widget || !isProxyWidget(widget)) return undefined
if (widget) {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
}
const { nodeId, widgetName: subWidgetName } = widget._overlay
const subNode = node.subgraph.getNodeById(nodeId)
if (!subNode) return undefined
return getInputSpecForWidget(subNode, subWidgetName)
return undefined
}
/**

View File

@@ -2,8 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
import { stripGraphPrefix, useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
@@ -17,6 +21,22 @@ function widget<T>(
return { nodeId, name, type, value, options: {}, ...extra }
}
function mockWidget(name: string, type = 'number'): IBaseWidget {
return { name, type } as IBaseWidget
}
function mockNode(id: string, widgets: IBaseWidget[] = []): LGraphNode {
return { id, widgets } as unknown as LGraphNode
}
function mockSubgraph(nodes: LGraphNode[]): LGraph {
const nodeMap = new Map(nodes.map((n) => [String(n.id), n]))
return {
getNodeById: (id: string | number | null | undefined) =>
id != null ? (nodeMap.get(String(id)) ?? null) : null
} as unknown as LGraph
}
describe('useWidgetValueStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -152,4 +172,93 @@ describe('useWidgetValueStore', () => {
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
})
})
describe('resolvePromotedWidget', () => {
it('returns null for missing node', () => {
const store = useWidgetValueStore()
const subgraph = mockSubgraph([])
expect(store.resolvePromotedWidget(subgraph, '99', 'seed')).toBeNull()
})
it('returns null for missing widget on existing node', () => {
const store = useWidgetValueStore()
const node = mockNode('42', [mockWidget('steps')])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('42', 'steps', 'number', 20))
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
})
it('returns null when widget exists on node but not in store', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
expect(store.resolvePromotedWidget(subgraph, '42', 'seed')).toBeNull()
})
it('returns correct state, widget, and node for registered widget', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
const registeredState = store.registerWidget(
widget('42', 'seed', 'number', 12345)
)
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
expect(result).not.toBeNull()
expect(result!.state).toBe(registeredState)
expect(result!.widget).toBe(w)
expect(result!.node).toBe(node)
})
it('state.value matches the store value (same reference)', () => {
const store = useWidgetValueStore()
const w = mockWidget('seed')
const node = mockNode('42', [w])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('42', 'seed', 'number', 100))
const result = store.resolvePromotedWidget(subgraph, '42', 'seed')
expect(result!.state.value).toBe(100)
result!.state.value = 200
expect(store.getWidget('42', 'seed')?.value).toBe(200)
})
it('handles stripGraphPrefix for scoped node IDs', () => {
const store = useWidgetValueStore()
const w = mockWidget('cfg')
const node = mockNode('7', [w])
const subgraph = mockSubgraph([node])
store.registerWidget(widget('7', 'cfg', 'number', 7.5))
// nodeId passed as bare '7' resolves to store key '7:cfg'
const result = store.resolvePromotedWidget(subgraph, '7', 'cfg')
expect(result).not.toBeNull()
expect(result!.state.value).toBe(7.5)
})
})
describe('stripGraphPrefix', () => {
it('strips single prefix', () => {
expect(stripGraphPrefix('graph1:42')).toBe('42')
})
it('strips multiple prefixes', () => {
expect(stripGraphPrefix('graph1:subgraph2:42')).toBe('42')
})
it('returns bare id unchanged', () => {
expect(stripGraphPrefix('42')).toBe('42')
})
it('handles numeric input', () => {
expect(stripGraphPrefix(42 as unknown as string)).toBe('42')
})
})
})

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IWidgetOptions
@@ -32,6 +33,7 @@ export interface WidgetState<
| 'type'
| 'value'
| 'options'
| 'hidden'
| 'label'
| 'serialize'
| 'disabled'
@@ -69,9 +71,27 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return widgetStates.value.get(makeKey(nodeId, widgetName))
}
function resolvePromotedWidget(
subgraph: LGraph,
nodeId: NodeId,
widgetName: string
): { state: WidgetState; widget: IBaseWidget; node: LGraphNode } | null {
const node = subgraph.getNodeById(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return null
const state = getWidget(stripGraphPrefix(nodeId), widgetName)
if (!state) return null
return { state, widget, node }
}
return {
registerWidget,
getWidget,
getNodeWidgets
getNodeWidgets,
resolvePromotedWidget
}
})

View File

@@ -0,0 +1,73 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { renameWidget } from './widgetUtil'
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
name: 'seed',
type: 'number',
value: 42,
options: {},
y: 0,
...overrides
} satisfies Partial<IBaseWidget> as IBaseWidget
}
function createMockNode(
widgets: IBaseWidget[] = [],
inputs: INodeInputSlot[] = []
): LGraphNode {
return {
widgets,
inputs
} satisfies Partial<Omit<LGraphNode, 'constructor'>> as unknown as LGraphNode
}
describe('renameWidget', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('sets widget label to new name', () => {
const widget = createMockWidget()
const node = createMockNode([widget])
renameWidget(widget, node, 'New Name')
expect(widget.label).toBe('New Name')
})
it('clears widget label when given empty string', () => {
const widget = createMockWidget()
widget.label = 'Old'
const node = createMockNode([widget])
renameWidget(widget, node, '')
expect(widget.label).toBeUndefined()
})
it('updates matching input label', () => {
const widget = createMockWidget()
const input = {
name: 'seed',
link: null,
widget: { name: 'seed' },
label: undefined as string | undefined
} satisfies Partial<INodeInputSlot> as INodeInputSlot
const node = createMockNode([widget], [input])
renameWidget(widget, node, 'Renamed')
expect(input.label).toBe('Renamed')
})
it('returns true on success', () => {
const widget = createMockWidget()
const node = createMockNode([widget])
expect(renameWidget(widget, node, 'New')).toBe(true)
})
})

View File

@@ -1,60 +1,19 @@
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and proxy widgets in subgraphs.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
parents?: SubgraphNode[]
newLabel: string
): boolean {
// For proxy widgets in subgraphs, we need to rename the original interior widget
if (isProxyWidget(widget) && parents?.length) {
const subgraph = parents[0].subgraph
if (!subgraph) {
console.error('Could not find subgraph for proxy widget')
return false
}
const interiorNode = subgraph.getNodeById(widget._overlay.nodeId)
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
return false
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return false
}
// Rename the original widget
originalWidget.label = newLabel || undefined
// Also rename the corresponding input on the interior node
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === widget._overlay.widgetName
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// Always rename the widget on the current node (either regular widget or proxy widget)
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
// Intentionally mutate the widget object here as it's a reference