mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 00:20:15 +00:00
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:
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
102
src/lib/litegraph/src/LGraphCanvas.clipboard.test.ts
Normal file
102
src/lib/litegraph/src/LGraphCanvas.clipboard.test.ts
Normal 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']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
86
src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts
Normal file
86
src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
this._widget ??= inputWidget
|
||||
this.events.dispatch('input-connected', {
|
||||
input: slot,
|
||||
widget: inputWidget
|
||||
widget: inputWidget,
|
||||
node
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user