feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)

## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-23 13:33:41 -08:00
committed by GitHub
parent d7546e68ef
commit c25f9a0e93
128 changed files with 7295 additions and 1931 deletions

View File

@@ -1,12 +1,17 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraphData,
createTestSubgraphNode
@@ -225,9 +230,48 @@ describe('Graph Clearing and Callbacks', () => {
// Verify nodes were actually removed
expect(graph.nodes.length).toBe(0)
})
test('clear() removes graph-scoped promotion and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, '10', 'seed')
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
type: 'number',
value: 1,
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
})
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
graph.clear()
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
})
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())

View File

@@ -9,6 +9,8 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import type { DragAndScaleState } from './DragAndScale'
@@ -352,6 +354,12 @@ export class LGraph
this.stop()
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
this.id = zeroUuid
this.revision = 0
@@ -965,17 +973,17 @@ export class LGraph
node.flags.ghost = true
}
// Register all widgets with the WidgetValueStore now that node has a valid ID.
// Widgets added before the node was in the graph deferred their setNodeId call.
node.graph = this
this._version++
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
}
node.graph = this
this._version++
this._nodes.push(node)
this._nodes_by_id[node.id] = node

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
import type {
ClipboardItems,
ExportedSubgraph,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
function createSerialisedNode(
id: number,
type: string,
proxyWidgets?: Array<[string, string]>
): ISerialisedNode {
return {
id,
type,
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: proxyWidgets ? { proxyWidgets } : {}
}
}
describe('remapClipboardSubgraphNodeIds', () => {
it('remaps pasted subgraph interior IDs and proxyWidgets references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = 1
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: {
id: -10,
bounding: [0, 0, 10, 10]
},
outputNode: {
id: -20,
bounding: [0, 0, 10, 10]
},
inputs: [],
outputs: [],
widgets: [],
nodes: [createSerialisedNode(1, 'test/node')],
links: [
{
id: 1,
type: '*',
origin_id: 1,
origin_slot: 0,
target_id: 1,
target_slot: 0
}
],
groups: []
}
const parsed: ClipboardItems = {
nodes: [createSerialisedNode(99, subgraphId, [['1', 'seed']])],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
remapClipboardSubgraphNodeIds(parsed, rootGraph)
const remappedSubgraph = parsed.subgraphs?.[0]
expect(remappedSubgraph).toBeDefined()
const remappedLink = remappedSubgraph?.links?.[0]
expect(remappedLink).toBeDefined()
const remappedInteriorId = remappedSubgraph?.nodes?.[0]?.id
expect(remappedInteriorId).not.toBe(1)
expect(remappedLink?.origin_id).toBe(remappedInteriorId)
expect(remappedLink?.target_id).toBe(remappedInteriorId)
const remappedNode = parsed.nodes?.[0]
expect(remappedNode).toBeDefined()
expect(remappedNode?.properties?.proxyWidgets).toStrictEqual([
[String(remappedInteriorId), 'seed']
])
})
})

View File

@@ -4065,6 +4065,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
for (const nodeInfo of allNodeInfo)
if (nodeInfo.type in subgraphIdMap)
nodeInfo.type = subgraphIdMap[nodeInfo.type]
remapClipboardSubgraphNodeIds(parsed, graph.rootGraph)
// Subgraphs
for (const info of parsed.subgraphs) {
@@ -4095,8 +4096,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
nodes.set(info.id, node)
info.id = -1
node.configure(info)
graph.add(node)
node.configure(info)
created.push(node)
}
@@ -8797,3 +8798,115 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
}
function patchLinkNodeIds(
links: { origin_id: NodeId; target_id: NodeId }[] | undefined,
remappedIds: Map<NodeId, NodeId>
) {
if (!links?.length) return
for (const link of links) {
const newOriginId = remappedIds.get(link.origin_id)
if (newOriginId !== undefined) link.origin_id = newOriginId
const newTargetId = remappedIds.get(link.target_id)
if (newTargetId !== undefined) link.target_id = newTargetId
}
}
function remapNodeId(
nodeId: string,
remappedIds: Map<NodeId, NodeId>
): NodeId | undefined {
const directMatch = remappedIds.get(nodeId)
if (directMatch !== undefined) return directMatch
if (!/^-?\d+$/.test(nodeId)) return undefined
const numericId = Number(nodeId)
if (!Number.isSafeInteger(numericId)) return undefined
return remappedIds.get(numericId)
}
function remapProxyWidgets(
info: ISerialisedNode,
remappedIds: Map<NodeId, NodeId> | undefined
) {
if (!remappedIds || remappedIds.size === 0) return
const proxyWidgets = info.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return
for (const entry of proxyWidgets) {
if (!Array.isArray(entry)) continue
const [nodeId] = entry
if (typeof nodeId !== 'string' || nodeId === '-1') continue
const remappedNodeId = remapNodeId(nodeId, remappedIds)
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
}
}
/**
* Remaps pasted subgraph interior node IDs that would collide with existing
* node IDs in the root graph. Also patches subgraph link node IDs and
* SubgraphNode `properties.proxyWidgets` references so promoted widget
* associations stay aligned with remapped interior IDs.
*/
export function remapClipboardSubgraphNodeIds(
parsed: ClipboardItems,
rootGraph: LGraph
): void {
const usedNodeIds = new Set<number>()
forEachNode(rootGraph, (node) => {
if (typeof node.id !== 'number') return
usedNodeIds.add(node.id)
if (rootGraph.state.lastNodeId < node.id)
rootGraph.state.lastNodeId = node.id
})
function nextUniqueNodeId() {
while (usedNodeIds.has(++rootGraph.state.lastNodeId));
const nextId = rootGraph.state.lastNodeId
usedNodeIds.add(nextId)
return nextId
}
const subgraphNodeIdMap = new Map<UUID, Map<NodeId, NodeId>>()
for (const subgraphInfo of parsed.subgraphs ?? []) {
const remappedIds = new Map<NodeId, NodeId>()
const interiorNodes = subgraphInfo.nodes ?? []
for (const nodeInfo of interiorNodes) {
if (typeof nodeInfo.id !== 'number') continue
if (usedNodeIds.has(nodeInfo.id)) {
const oldId = nodeInfo.id
const newId = nextUniqueNodeId()
remappedIds.set(oldId, newId)
nodeInfo.id = newId
continue
}
usedNodeIds.add(nodeInfo.id)
if (rootGraph.state.lastNodeId < nodeInfo.id)
rootGraph.state.lastNodeId = nodeInfo.id
}
if (remappedIds.size > 0) {
patchLinkNodeIds(subgraphInfo.links, remappedIds)
subgraphNodeIdMap.set(subgraphInfo.id, remappedIds)
}
}
const allNodeInfo: ISerialisedNode[] = [
parsed.nodes ? [parsed.nodes] : [],
parsed.subgraphs ? parsed.subgraphs.map((s) => s.nodes ?? []) : []
].flat(2)
for (const nodeInfo of allNodeInfo) {
if (typeof nodeInfo.type !== 'string') continue
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
}
}

View File

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

View File

@@ -144,7 +144,7 @@ export { isColorable } from './utils/type'
export { createUuidv4 } from './utils/uuid'
export type { UUID } from './utils/uuid'
export { truncateText } from './utils/textUtils'
export { getWidgetStep } from './utils/widget'
export { getWidgetStep, resolveNodeRootGraphId } from './utils/widget'
export { distributeSpace, type SpaceRequest } from './utils/spaceDistribution'
export { BaseWidget } from './widgets/BaseWidget'

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
import type { SubgraphPromotionEntry } from '@/services/subgraphPseudoWidgetCache'
function makeView(entry: SubgraphPromotionEntry) {
return {
key: `${entry.interiorNodeId}:${entry.widgetName}`
}
}
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ interiorNodeId: '1', widgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
expect(second).toBe(first)
expect(second[0]).toBe(first[0])
})
test('preserves view identity while reflecting order changes', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(reordered[0]).toBe(firstPass[1])
expect(reordered[1]).toBe(firstPass[0])
})
test('deduplicates by first occurrence and clears stale cache entries', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const first = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(first.map((view) => view.key)).toStrictEqual([
'1:widgetA',
'1:widgetB'
])
manager.reconcile(
[{ interiorNodeId: '1', widgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
],
makeView
)
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
})

View File

@@ -0,0 +1,86 @@
type PromotionEntry = {
interiorNodeId: string
widgetName: string
}
type CreateView<TView> = (entry: PromotionEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
*
* Keeps object identity stable by key while preserving the current
* promotion order and deduplicating duplicate entries by first occurrence.
*/
export class PromotedWidgetViewManager<TView> {
private viewCache = new Map<string, TView>()
private cachedViews: TView[] | null = null
private cachedEntriesRef: readonly PromotionEntry[] | null = null
reconcile(
entries: readonly PromotionEntry[],
createView: CreateView<TView>
): TView[] {
if (this.cachedViews && entries === this.cachedEntriesRef)
return this.cachedViews
const views: TView[] = []
const seenKeys = new Set<string>()
for (const entry of entries) {
const key = this.makeKey(entry.interiorNodeId, entry.widgetName)
if (seenKeys.has(key)) continue
seenKeys.add(key)
const existing = this.viewCache.get(key)
if (existing) {
views.push(existing)
continue
}
const nextView = createView(entry)
this.viewCache.set(key, nextView)
views.push(nextView)
}
for (const key of this.viewCache.keys()) {
if (!seenKeys.has(key)) this.viewCache.delete(key)
}
this.cachedViews = views
this.cachedEntriesRef = entries
return views
}
getOrCreate(
interiorNodeId: string,
widgetName: string,
createView: () => TView
): TView {
const key = this.makeKey(interiorNodeId, widgetName)
const cached = this.viewCache.get(key)
if (cached) return cached
const view = createView()
this.viewCache.set(key, view)
return view
}
remove(interiorNodeId: string, widgetName: string): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName))
this.invalidateMemoizedList()
}
clear(): void {
this.viewCache.clear()
this.invalidateMemoizedList()
}
invalidateMemoizedList(): void {
this.cachedViews = null
this.cachedEntriesRef = null
}
private makeKey(interiorNodeId: string, widgetName: string): string {
return `${interiorNodeId}:${widgetName}`
}
}

View File

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

View File

@@ -29,11 +29,18 @@ 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 {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
import type { SubgraphInput } from './SubgraphInput'
const workflowSvg = new Image()
@@ -64,7 +71,55 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
}
override widgets: IBaseWidget[] = []
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
// so we use declare + defineProperty instead.
declare widgets: IBaseWidget[]
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
return this._promotedViewManager.reconcile(entries, (entry) =>
createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName)
)
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) return undefined
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
}
// Fallback: find via subgraph input slot connection
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === widgetName
)
if (!subgraphInput) return undefined
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) continue
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) continue
const w = inputNode.getWidgetFromSlot(targetInput)
if (w) return [String(inputNode.id), w.name]
}
return undefined
}
/** Manages lifecycle of all subgraph event listeners */
private _eventAbortController = new AbortController()
@@ -79,6 +134,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
super(subgraph.name, subgraph.id)
this.graph = graph
// Synthetic widgets getter — SubgraphNodes have no native widgets.
Object.defineProperty(this, 'widgets', {
get: () => this._getPromotedViews(),
set: () => {
if (import.meta.env.DEV)
console.warn(
'Cannot manually set widgets on SubgraphNode; use the promotion system.'
)
},
configurable: true,
enumerable: true
})
// Update this node when the subgraph input / output slots are changed
const subgraphEvents = this.subgraph.events
const { signal } = this._eventAbortController
@@ -88,13 +156,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(e) => {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find((i) => i.name == name)
const existingInput = this.inputs.find((i) => i.name === name)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget)
this._setWidget(subgraphInput, existingInput, widget, input?.widget)
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
if (widget && inputNode)
this._setWidget(
subgraphInput,
existingInput,
widget,
input?.widget,
inputNode
)
return
}
const input = this.addInput(name, type)
@@ -200,13 +274,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
if (input._widget) return
const widget = subgraphInput._widget
if (!widget) return
// If this widget is already promoted, demote it first
// so it transitions cleanly to being linked via SubgraphInput.
const nodeId = String(e.detail.node.id)
if (
usePromotionStore().isPromoted(
this.rootGraph.id,
this.id,
nodeId,
widget.name
)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
nodeId,
widget.name
)
}
const widgetLocator = e.detail.input.widget
this._setWidget(subgraphInput, input, widget, widgetLocator)
this._setWidget(
subgraphInput,
input,
widget,
widgetLocator,
e.detail.node
)
},
{ signal }
)
@@ -218,7 +315,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
@@ -276,8 +373,42 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override _internalConfigureAfterSlots() {
// Reset widgets
this.widgets.length = 0
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
// Clear view cache — forces re-creation on next getter access.
// Do NOT clear properties.proxyWidgets — it was already populated
// from serialized data by super.configure(info) before this runs.
this._promotedViewManager.clear()
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { interiorNodeId: resolved[0], widgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
)
}
return null
}
return { interiorNodeId: nodeId, widgetName }
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy -1 format doesn't persist
if (raw.some(([id]) => id === '-1')) {
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
}
// Check all inputs for connected widgets
for (const input of this.inputs) {
@@ -323,7 +454,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
}
}
@@ -332,69 +469,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
_widget: Readonly<IBaseWidget>,
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 nodeId = String(interiorNode.id)
const widgetName = _widget.name
Object.assign(promotedWidget, {
get name() {
return subgraphInput.name
},
set name(value) {
console.warn(
'Promoted widget: setting name is not allowed',
this,
value
)
},
get localized_name() {
return subgraphInput.localized_name
},
set localized_name(value) {
console.warn(
'Promoted widget: setting localized_name is not allowed',
this,
value
)
},
get label() {
return subgraphInput.label
},
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
)
}
})
// Add to promotion store
usePromotionStore().promote(this.rootGraph.id, this.id, nodeId, widgetName)
const widgetCount = this.inputs.filter((i) => i.widget).length
this.widgets.splice(widgetCount, 0, promotedWidget)
// Dispatch widget-promoted event
this.subgraph.events.dispatch('widget-promoted', {
widget: promotedWidget,
subgraphNode: this
})
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(nodeId, widgetName, () =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name)
)
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
@@ -403,7 +491,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 = view
// Dispatch widget-promoted event
this.subgraph.events.dispatch('widget-promoted', {
widget: view,
subgraphNode: this
})
}
/**
@@ -535,40 +629,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return nodes
}
/** Clear the DOM position override for a promoted view's interior widget. */
private _clearDomOverrideForView(view: PromotedWidgetView): void {
const node = this.subgraph.getNodeById(view.sourceNodeId)
if (!node) return
const interiorWidget = node.widgets?.find(
(w: IBaseWidget) => w.name === view.sourceWidgetName
)
if (
interiorWidget &&
'id' in interiorWidget &&
('element' in interiorWidget || 'component' in interiorWidget)
) {
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
}
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
override removeWidgetByName(name: string): void {
const widget = this.widgets.find((w) => w.name === name)
if (widget) {
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
}
super.removeWidgetByName(name)
if (widget) this.ensureWidgetRemoved(widget)
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (this.widgets.includes(widget)) {
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(
this.rootGraph.id,
this.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
this._promotedViewManager.remove(
widget.sourceNodeId,
widget.sourceWidgetName
)
}
super.ensureWidgetRemoved(widget)
for (const input of this.inputs) {
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
}
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
}
override onRemoved(): void {
// Clean up all subgraph event listeners
this._eventAbortController.abort()
// Clean up all promoted widgets
for (const widget of this.widgets) {
if ('isProxyWidget' in widget && widget.isProxyWidget) continue
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
}
usePromotionStore().setPromotions(this.rootGraph.id, this.id, [])
this._promotedViewManager.clear()
for (const input of this.inputs) {
if (
input._listenerController &&
@@ -610,36 +737,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization
for (let i = 0; i < this.widgets.length; i++) {
const widget = this.widgets[i]
const input = this.inputs.find((inp) => inp.name === widget.name)
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
if (input) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (!subgraphInput) continue
if (subgraphInput) {
// Find all widgets connected to this subgraph input
const connectedWidgets = subgraphInput.getConnectedWidgets()
// Update the value of all connected widgets
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = widget.value
}
}
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Call parent serialize method
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
this.id
)
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
return super.serialize()
}
override clone() {
const clone = super.clone()
// force reasign so domWidgets reset ownership
this.properties.proxyWidgets = this.properties.proxyWidgets
//TODO: Consider deep cloning subgraphs here.
//It's the safest place to prevent creation of linked subgraphs

View File

@@ -6,12 +6,25 @@
* in their test files. Each fixture provides a clean, pre-configured subgraph
* setup for different testing scenarios.
*/
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { test } from '../../__fixtures__/testExtensions'
import { test as baseTest } from '../../__fixtures__/testExtensions'
const test = baseTest.extend({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
await use(undefined)
},
{ auto: true }
]
})
import {
createEventCapture,
createNestedSubgraphs,

View File

@@ -411,14 +411,6 @@ export interface IBaseWidget<
hidden?: boolean
advanced?: boolean
/**
* 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
*/
promoted?: boolean
tooltip?: string
// TODO: Confirm this format

View File

@@ -1,7 +1,11 @@
import { describe, expect, test } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph'
import { getWidgetStep } from '@/lib/litegraph/src/litegraph'
import {
getWidgetStep,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
describe('getWidgetStep', () => {
test('should return step2 when available', () => {
@@ -42,3 +46,27 @@ describe('getWidgetStep', () => {
expect(getWidgetStep(optionsWithZeroStep)).toBe(1)
})
})
type GraphIdNode = Pick<LGraphNode, 'graph'>
describe('resolveNodeRootGraphId', () => {
test('returns node rootGraph id when node belongs to a graph', () => {
const node = {
graph: {
rootGraph: {
id: 'subgraph-root-id'
}
}
} as GraphIdNode
expect(resolveNodeRootGraphId(node)).toBe('subgraph-root-id')
})
test('returns fallback graph id when node graph is missing', () => {
const node = {
graph: null
} as GraphIdNode
expect(resolveNodeRootGraphId(node, 'app-root-id')).toBe('app-root-id')
})
})

View File

@@ -1,4 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
/**
* The step value for numeric widgets.
@@ -23,3 +25,17 @@ export function evaluateInput(input: string): number | undefined {
if (isNaN(newValue)) return undefined
return newValue
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>,
fallbackGraphId: UUID
): UUID
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>,
fallbackGraphId?: UUID
): UUID | undefined {
return node.graph?.rootGraph.id ?? fallbackGraphId
}

View File

@@ -36,12 +36,13 @@ export class AssetWidget
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
options: DrawWidgetOptions
) {
const { width, showText = true } = options
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, { width, showText })
this.drawWidgetShape(ctx, options)
if (showText) {
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -25,14 +25,17 @@ function createTestWidget(
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
let store: ReturnType<typeof useWidgetValueStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useWidgetValueStore()
graph = new LGraph()
node = new LGraphNode('TestNode')
node.id = 1
graph.add(node)
})
describe('metadata properties before registration', () => {
@@ -41,15 +44,13 @@ describe('BaseWidget store integration', () => {
label: 'My Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
advanced: true
})
expect(widget.label).toBe('My Label')
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
it('allows setting properties without store', () => {
@@ -59,13 +60,11 @@ describe('BaseWidget store integration', () => {
widget.hidden = true
widget.disabled = true
widget.advanced = true
widget.promoted = true
expect(widget.label).toBe('New Label')
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
})
@@ -76,8 +75,7 @@ describe('BaseWidget store integration', () => {
label: 'Store Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
advanced: true
})
widget.setNodeId(1)
@@ -85,7 +83,6 @@ describe('BaseWidget store integration', () => {
expect(widget.hidden).toBe(true)
expect(widget.disabled).toBe(true)
expect(widget.advanced).toBe(true)
expect(widget.promoted).toBe(true)
})
it('writes to store when registered', () => {
@@ -96,12 +93,10 @@ describe('BaseWidget store integration', () => {
widget.hidden = true
widget.disabled = true
widget.advanced = true
widget.promoted = true
const state = store.getWidget(1, 'writeWidget')
const state = store.getWidget(graph.id, 1, 'writeWidget')
expect(state?.label).toBe('Updated Label')
expect(state?.disabled).toBe(true)
expect(state?.promoted).toBe(true)
expect(widget.hidden).toBe(true)
expect(widget.advanced).toBe(true)
@@ -112,9 +107,9 @@ describe('BaseWidget store integration', () => {
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(1, 'valueWidget')?.value).toBe(99)
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
const state = store.getWidget(1, 'valueWidget')!
const state = store.getWidget(graph.id, 1, 'valueWidget')!
state.value = 55
expect(widget.value).toBe(55)
})
@@ -128,12 +123,11 @@ describe('BaseWidget store integration', () => {
label: 'Auto Label',
hidden: true,
disabled: true,
advanced: true,
promoted: true
advanced: true
})
widget.setNodeId(1)
const state = store.getWidget(1, 'autoRegWidget')
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.name).toBe('autoRegWidget')
@@ -141,7 +135,6 @@ describe('BaseWidget store integration', () => {
expect(state?.value).toBe(100)
expect(state?.label).toBe('Auto Label')
expect(state?.disabled).toBe(true)
expect(state?.promoted).toBe(true)
expect(state?.options).toEqual({ min: 0, max: 100 })
expect(widget.hidden).toBe(true)
@@ -152,10 +145,9 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(1, 'defaultsWidget')
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
expect(state).toBeDefined()
expect(state?.disabled).toBe(false)
expect(state?.promoted).toBe(false)
expect(state?.label).toBeUndefined()
expect(widget.hidden).toBeUndefined()
@@ -166,7 +158,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(1, 'valuesWidget')?.value).toBe(77)
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
})
})
@@ -186,7 +178,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = undefined
const state = store.getWidget(1, 'testWidget')
const state = store.getWidget(graph.id, 1, 'testWidget')
expect(state?.disabled).toBe(false)
})
})

View File

@@ -16,6 +16,7 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -24,6 +25,8 @@ export interface DrawWidgetOptions {
width: number
/** Synonym for "low quality". */
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
}
interface DrawTruncatingTextOptions extends DrawWidgetOptions {
@@ -100,12 +103,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.disabled = value ?? false
}
get promoted(): boolean | undefined {
return this._state.promoted
}
set promoted(value: boolean | undefined) {
this._state.promoted = value ?? false
}
element?: HTMLElement
callback?(
value: TWidget['value'],
@@ -138,7 +135,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
* Once set, value reads/writes will be delegated to the store.
*/
setNodeId(nodeId: NodeId): void {
this._state = useWidgetValueStore().registerWidget({
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(graphId, {
...this._state,
nodeId
})
@@ -181,7 +181,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
labelBaseline,
label,
disabled,
promoted,
value,
linkedWidgets,
...safeValues
@@ -195,19 +194,32 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
value,
label,
disabled: disabled ?? false,
promoted: promoted ?? false,
serialize: this.serialize,
options: this.options
}
}
get outline_color() {
if (this.promoted) return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
getOutlineColor(suppressPromotedOutline = false) {
const graphId = this.node.graph?.rootGraph.id
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(
graphId,
String(this.node.id),
this.name
)
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
}
get outline_color() {
return this.getOutlineColor()
}
get background_color() {
return LiteGraph.WIDGET_BGCOLOR
}
@@ -262,13 +274,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawWidgetShape(
ctx: CanvasRenderingContext2D,
{ width, showText }: DrawWidgetOptions
{ width, showText, suppressPromotedOutline }: DrawWidgetOptions
): void {
const { height, y } = this
const { margin } = BaseWidget
ctx.textAlign = 'left'
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.fillStyle = this.background_color
ctx.beginPath()
@@ -289,7 +301,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawVueOnlyWarning(
ctx: CanvasRenderingContext2D,
{ width }: DrawWidgetOptions,
{ width, suppressPromotedOutline }: DrawWidgetOptions,
label: string
): void {
const { y, height } = this
@@ -299,7 +311,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -11,12 +11,13 @@ export class BooleanWidget
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
options: DrawWidgetOptions
) {
const { width, showText = true } = options
const { height, y } = this
const { margin } = BaseWidget
this.drawWidgetShape(ctx, { width, showText })
this.drawWidgetShape(ctx, options)
ctx.fillStyle = this.value ? '#89A' : '#333'
ctx.beginPath()

View File

@@ -23,7 +23,7 @@ export class ButtonWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -41,7 +41,7 @@ export class ButtonWidget
// Draw button outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeRect(margin, y, width - margin * 2, height)
}

View File

@@ -23,7 +23,7 @@ export class ChartWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

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

@@ -23,7 +23,7 @@ export class FileUploadWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class GalleriaWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class ImageCompareWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -33,7 +33,7 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
): void {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -145,10 +145,10 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
// Draw outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
// Draw value
ctx.beginPath()
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.arc(
arc_center.x,
arc_center.y,

View File

@@ -23,7 +23,7 @@ export class MarkdownWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class MultiSelectWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class SelectButtonWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -20,7 +20,7 @@ export class SliderWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -43,7 +43,7 @@ export class SliderWidget
// Draw outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.outline_color
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeRect(margin, y, width - margin * 2, height)
}

View File

@@ -21,12 +21,13 @@ export class TextWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
options: DrawWidgetOptions
) {
const { width, showText = true } = options
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, { width, showText })
this.drawWidgetShape(ctx, options)
if (showText) {
this.drawTruncatingText({ ctx, width, leftPadding: 0, rightPadding: 0 })