mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 09:44:06 +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,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)] items-center"
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="missing-nodes-warning"
|
||||
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
|
||||
:class="isCloud ? 'border-b' : ''"
|
||||
>
|
||||
|
||||
209
src/components/graph/DomWidgets.test.ts
Normal file
209
src/components/graph/DomWidgets.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
type TestWidget = BaseDOMWidget<object | string>
|
||||
|
||||
function createNode(
|
||||
graph: LGraph,
|
||||
id: number,
|
||||
title: string,
|
||||
pos: [number, number]
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = id
|
||||
node.pos = [...pos]
|
||||
node.size = [240, 120]
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function createWidget(id: string, node: LGraphNode, y = 12): TestWidget {
|
||||
return {
|
||||
id,
|
||||
node,
|
||||
name: 'test_widget',
|
||||
type: 'custom',
|
||||
value: '',
|
||||
options: {},
|
||||
y,
|
||||
width: 120,
|
||||
computedHeight: 40,
|
||||
margin: 10,
|
||||
isVisible: () => true
|
||||
} as unknown as TestWidget
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
return {
|
||||
graph,
|
||||
low_quality: false,
|
||||
read_only: false,
|
||||
isNodeVisible: vi.fn(() => true)
|
||||
} as unknown as LGraphCanvas
|
||||
}
|
||||
|
||||
function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,13 @@ import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { getDomWidgetZIndex } from '@/components/graph/widgets/domWidgetZIndex'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -29,37 +31,76 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the widget's node is in the current graph
|
||||
const node = widget.node
|
||||
const isInCorrectGraph = currentGraph?.nodes.includes(node)
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
|
||||
widgetState.visible =
|
||||
!!isInCorrectGraph &&
|
||||
lgCanvas.isNodeVisible(node) &&
|
||||
isInCorrectGraph &&
|
||||
nodeVisible &&
|
||||
!(widget.options.hideOnZoom && lowQuality)
|
||||
|
||||
if (widgetState.visible && node) {
|
||||
if (widgetState.visible) {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
|
||||
widgetState.size = [
|
||||
(widget.width ?? node.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
]
|
||||
// TODO: optimize this logic as it's O(n), where n is the number of nodes
|
||||
widgetState.zIndex = lgCanvas.graph?.nodes.indexOf(node) ?? -1
|
||||
widgetState.size = [
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -68,7 +68,11 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -143,8 +147,17 @@ onMounted(() => {
|
||||
widget.options.selectOn ?? ['focus', 'click'],
|
||||
() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
lgCanvas?.selectNode(widgetState.widget.node)
|
||||
lgCanvas?.bringToFront(widgetState.widget.node)
|
||||
if (!lgCanvas) return
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
37
src/components/graph/widgets/domWidgetZIndex.test.ts
Normal file
37
src/components/graph/widgets/domWidgetZIndex.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
it('follows graph node ordering when node.order is stale', () => {
|
||||
const graph = new LGraph()
|
||||
const first = new LGraphNode('first')
|
||||
const second = new LGraphNode('second')
|
||||
graph.add(first)
|
||||
graph.add(second)
|
||||
|
||||
first.order = 0
|
||||
second.order = 1
|
||||
|
||||
const nodes = (graph as unknown as { _nodes: LGraphNode[] })._nodes
|
||||
nodes.splice(nodes.indexOf(first), 1)
|
||||
nodes.push(first)
|
||||
|
||||
expect(first.order).toBe(0)
|
||||
expect(second.order).toBe(1)
|
||||
|
||||
expect(getDomWidgetZIndex(first, graph)).toBe(1)
|
||||
expect(getDomWidgetZIndex(second, graph)).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to node.order when node is not in current graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('orphan')
|
||||
node.order = 7
|
||||
|
||||
expect(getDomWidgetZIndex(node, graph)).toBe(7)
|
||||
expect(getDomWidgetZIndex(node, undefined)).toBe(7)
|
||||
})
|
||||
})
|
||||
13
src/components/graph/widgets/domWidgetZIndex.ts
Normal file
13
src/components/graph/widgets/domWidgetZIndex.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export function getDomWidgetZIndex(
|
||||
node: LGraphNode,
|
||||
currentGraph: LGraphNode['graph'] | undefined
|
||||
): number {
|
||||
if (!currentGraph) return node.order ?? -1
|
||||
|
||||
const graphOrder = currentGraph.nodes.indexOf(node)
|
||||
if (graphOrder === -1) return node.order ?? -1
|
||||
|
||||
return graphOrder
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import { st } from '@/i18n'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -229,12 +228,6 @@ function handleTitleEdit(newTitle: string) {
|
||||
function handleTitleCancel() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
if (!selectedSingleNode.value) return
|
||||
;(selectedSingleNode.value as SubgraphNode).properties.proxyWidgets = value
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -330,7 +323,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
@update:proxy-widgets="handleProxyWidgetsUpdate"
|
||||
/>
|
||||
<TabNormalInputs
|
||||
v-else-if="activeTab === 'parameters'"
|
||||
|
||||
@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
@@ -69,27 +69,28 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
if (!parents.length) return false
|
||||
const proxyWidgets = parseProxyWidgets(parents[0].properties.proxyWidgets)
|
||||
|
||||
// For proxy widgets (already promoted), check using overlay information
|
||||
if (isProxyWidget(widget)) {
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widget._overlay.nodeId == nodeId &&
|
||||
widget._overlay.widgetName === widgetName
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
}
|
||||
return promotionStore.isPromoted(
|
||||
parent.rootGraph.id,
|
||||
parent.id,
|
||||
String(widgetNode.id),
|
||||
widget.name
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
@@ -3,25 +3,22 @@ import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
triggerRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
@@ -32,12 +29,9 @@ const { node } = defineProps<{
|
||||
node: SubgraphNode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:proxyWidgets': [value: ProxyWidgetsProperty]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -46,18 +40,9 @@ const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
// Use customRef to track proxyWidgets changes
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
if (!value) return
|
||||
emit('update:proxyWidgets', value)
|
||||
}
|
||||
}))
|
||||
const promotionEntries = computed(() =>
|
||||
promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
)
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
@@ -81,22 +66,18 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.value
|
||||
const entries = promotionEntries.value
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Map proxyWidgets to actual proxy widgets in the correct order
|
||||
const result: NodeWidgetsList = []
|
||||
for (const [nodeId, widgetName] of proxyWidgetsOrder) {
|
||||
// Find the proxy widget that matches this nodeId and widgetName
|
||||
for (const { interiorNodeId, widgetName } of entries) {
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
if (isPromotedWidgetView(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
String(w.sourceNodeId) === interiorNodeId &&
|
||||
w.sourceWidgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
@@ -108,9 +89,7 @@ const widgetsList = computed((): NodeWidgetsList => {
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||
|
||||
// Get all widgets from interior nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((interiorNode) => {
|
||||
const { widgets = [] } = interiorNode
|
||||
return widgets
|
||||
@@ -118,13 +97,15 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
.map((widget) => ({ node: interiorNode, widget }))
|
||||
})
|
||||
|
||||
// Filter out widgets that are already promoted using tuple matching
|
||||
return allInteriorWidgets.filter(({ node: interiorNode, widget }) => {
|
||||
return !proxyWidgetsValue.some(
|
||||
([nodeId, widgetName]) =>
|
||||
interiorNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
})
|
||||
return allInteriorWidgets.filter(
|
||||
({ node: interiorNode, widget }) =>
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
@@ -181,13 +162,13 @@ function setDraggableState() {
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
// Update proxyWidgets order
|
||||
const pw = proxyWidgets.value
|
||||
const [w] = pw.splice(oldPosition, 1)
|
||||
pw.splice(newPosition, 0, w)
|
||||
proxyWidgets.value = pw
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -80,26 +81,14 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
// For proxy widgets (already promoted), we need to find the original interior node and widget
|
||||
if (isProxyWidget(widget)) {
|
||||
const subgraph = parents[0].subgraph
|
||||
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) {
|
||||
console.error('Could not resolve source widget for promoted widget')
|
||||
return
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return
|
||||
}
|
||||
|
||||
demoteWidget(interiorNode, originalWidget, parents)
|
||||
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
|
||||
204
src/components/rightSidePanel/parameters/WidgetItem.test.ts
Normal file
204
src/components/rightSidePanel/parameters/WidgetItem.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn(),
|
||||
StubWidgetComponent: {
|
||||
name: 'StubWidget',
|
||||
props: ['widget', 'modelValue', 'nodeId', 'nodeType'],
|
||||
template: '<div class="stub-widget" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { setDirty: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
useFavoritedWidgetsStore: () => ({
|
||||
isFavorited: vi.fn().mockReturnValue(false),
|
||||
toggleFavorite: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => StubWidgetComponent,
|
||||
shouldExpand: () => false
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue',
|
||||
() => ({
|
||||
default: StubWidgetComponent
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
fallbackNodeTitle: 'Untitled'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
isSubgraphNode: () => false,
|
||||
graph: { rootGraph: { id: 'test-graph-id' } },
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'option_a',
|
||||
y: 0,
|
||||
options: {
|
||||
values: ['option_a', 'option_b', 'option_c']
|
||||
},
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return new MockPromotedWidgetView() as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function mountWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
) {
|
||||
return mount(WidgetItem, {
|
||||
props: { widget, node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
EditableText: { template: '<span />' },
|
||||
WidgetActions: { template: '<span />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetItem', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('promoted widget options', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
})
|
||||
const wrapper = mountWidgetItem(widget)
|
||||
const stub = wrapper.findComponent(StubWidgetComponent)
|
||||
|
||||
expect(stub.props('widget').options).toEqual({
|
||||
values: ['a', 'b', 'c']
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const wrapper = mountWidgetItem(widget)
|
||||
const stub = wrapper.findComponent(StubWidgetComponent)
|
||||
|
||||
expect(stub.props('widget').options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const wrapper = mountWidgetItem(widget)
|
||||
const stub = wrapper.findComponent(StubWidgetComponent)
|
||||
|
||||
expect(stub.props('widget').type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const wrapper = mountWidgetItem(widget)
|
||||
const stub = wrapper.findComponent(StubWidgetComponent)
|
||||
|
||||
expect(stub.props('widget').name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
const wrapper = mountWidgetItem(widget)
|
||||
const stub = wrapper.findComponent(StubWidgetComponent)
|
||||
|
||||
expect(stub.props('widget').value).toBe('model_a.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -15,8 +15,13 @@ import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -50,6 +55,8 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
@@ -58,18 +65,32 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
const enhancedWidget = computed(() => {
|
||||
// Get shared enhancements (reactive value, controlWidget, spec, nodeType, etc.)
|
||||
const enhancements = getSharedWidgetEnhancements(node, widget)
|
||||
return { ...widget, ...enhancements }
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
}
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
let sourceNode: LGraphNode | null = node
|
||||
if (isProxyWidget(widget)) {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
@@ -176,7 +197,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
<component
|
||||
:is="widgetComponent"
|
||||
v-model="widgetValue"
|
||||
:widget="enhancedWidget"
|
||||
:widget="simplifiedWidget"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
|
||||
@@ -1,63 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
customRef,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
triggerRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
isRecommendedWidget,
|
||||
matchesPropertyItem,
|
||||
matchesWidgetItem,
|
||||
promoteWidget,
|
||||
pruneDisconnected,
|
||||
widgetItemToProperty
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
pruneDisconnected
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const draggableItems = ref()
|
||||
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
},
|
||||
set(value?: ProxyWidgetsProperty) {
|
||||
trigger()
|
||||
const node = activeNode.value
|
||||
if (!value) return
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
node.properties.proxyWidgets = value
|
||||
}
|
||||
}))
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
})
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
@@ -67,21 +47,27 @@ const activeNode = computed(() => {
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
if (!activeNode.value) return []
|
||||
const node = activeNode.value
|
||||
function mapWidgets([id, name]: [string, string]): WidgetItem[] {
|
||||
if (id === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === name)
|
||||
if (!node) return []
|
||||
|
||||
return promotionEntries.value.flatMap(
|
||||
({ interiorNodeId, widgetName }): WidgetItem[] => {
|
||||
if (interiorNodeId === '-1') {
|
||||
const widget = node.widgets.find((w) => w.name === widgetName)
|
||||
if (!widget) return []
|
||||
return [
|
||||
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
|
||||
]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
|
||||
if (!wNode) return []
|
||||
const widget = getPromotableWidgets(wNode).find(
|
||||
(w) => w.name === widgetName
|
||||
)
|
||||
if (!widget) return []
|
||||
return [[{ id: -1, title: '(Linked)', type: '' }, widget]]
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
const wNode = node.subgraph._nodes_by_id[id]
|
||||
if (!wNode?.widgets) return []
|
||||
const widget = wNode.widgets.find((w) => w.name === name)
|
||||
if (!widget) return []
|
||||
return [[wNode, widget]]
|
||||
}
|
||||
return proxyWidgets.value.flatMap(mapWidgets)
|
||||
)
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
@@ -89,7 +75,14 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
proxyWidgets.value = value.map(widgetItemToProperty)
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => ({
|
||||
interiorNodeId: String(n.id),
|
||||
widgetName: getWidgetName(w)
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -110,9 +103,14 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const widgets = proxyWidgets.value
|
||||
return interiorWidgets.value.filter(
|
||||
(widgetItem: WidgetItem) => !widgets.some(matchesPropertyItem(widgetItem))
|
||||
([n, w]: WidgetItem) =>
|
||||
!promotionStore.isPromoted(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
w.name
|
||||
)
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -145,47 +143,56 @@ function toKey(item: WidgetItem) {
|
||||
return `${item[0].id}: ${item[1].name}`
|
||||
}
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
if (!n.widgets) return []
|
||||
return n.widgets.map((w: IBaseWidget) => [n, w])
|
||||
return getPromotableWidgets(n).map((w) => [n, w])
|
||||
}
|
||||
function demote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return []
|
||||
if (!subgraphNode) return
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
triggerRef(proxyWidgets)
|
||||
promotionStore.demote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
)
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return []
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
triggerRef(proxyWidgets)
|
||||
promotionStore.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
}
|
||||
function showAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
filteredCandidates.value.map(widgetItemToProperty)
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
for (const [n, w] of filteredCandidates.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
proxyWidgets.value = proxyWidgets.value.filter(
|
||||
(propertyItem) =>
|
||||
!filteredActive.value.some(matchesWidgetItem(propertyItem)) ||
|
||||
propertyItem[0] === '-1'
|
||||
)
|
||||
for (const [n, w] of filteredActive.value) {
|
||||
if (String(n.id) === '-1') continue
|
||||
promotionStore.demote(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
)
|
||||
}
|
||||
}
|
||||
function showRecommended() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
const widgets = proxyWidgets.value
|
||||
const toAdd: ProxyWidgetsProperty =
|
||||
recommendedWidgets.value.map(widgetItemToProperty)
|
||||
widgets.push(...toAdd)
|
||||
proxyWidgets.value = widgets
|
||||
for (const [n, w] of recommendedWidgets.value) {
|
||||
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggableState() {
|
||||
|
||||
@@ -245,10 +245,10 @@ General-purpose composables:
|
||||
| `useServerLogs` | Manages server log display |
|
||||
| `useTemplateWorkflows` | Manages template workflow loading, selection, and display |
|
||||
| `useTreeExpansion` | Handles tree node expansion state |
|
||||
| `useValueTransform` | Transforms values between formats |
|
||||
| `useWorkflowAutoSave` | Handles automatic workflow saving |
|
||||
| `useWorkflowPersistence` | Manages workflow persistence |
|
||||
| `useWorkflowValidation` | Validates workflow integrity |
|
||||
|
||||
| `useWorkflowAutoSave` | Handles automatic workflow saving |
|
||||
| `useWorkflowPersistence` | Manages workflow persistence |
|
||||
| `useWorkflowValidation` | Validates workflow integrity |
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
|
||||
@@ -20,16 +20,15 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
// Mock the litegraph module
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
Reroute: class Reroute {
|
||||
constructor() {}
|
||||
},
|
||||
LGraphEventMode: {
|
||||
ALWAYS: 0,
|
||||
NEVER: 2,
|
||||
BYPASS: 4
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Reroute: class Reroute {
|
||||
constructor() {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock Positionable objects
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
@@ -26,7 +31,7 @@ describe('Node Reactivity', () => {
|
||||
}
|
||||
|
||||
it('widget values are reactive through the store', async () => {
|
||||
const { node } = createTestGraph()
|
||||
const { node, graph } = createTestGraph()
|
||||
const store = useWidgetValueStore()
|
||||
const widget = node.widgets![0]
|
||||
|
||||
@@ -36,12 +41,13 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
const widgetValue = computed(
|
||||
() => store.getWidget(node.id, 'testnum')?.value
|
||||
)
|
||||
const widgetValue = computed(() => state.value)
|
||||
watch(widgetValue, onValueChange)
|
||||
|
||||
widget.value = 42
|
||||
@@ -62,9 +68,10 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const widgetValue = computed(
|
||||
() => store.getWidget(node.id, 'testnum')?.value
|
||||
)
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
watch(widgetValue, onValueChange)
|
||||
|
||||
node.widgets![0].value = 99
|
||||
@@ -74,3 +81,36 @@ describe('Node Reactivity', () => {
|
||||
expect(widgetValue.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = vueNode?.widgets?.find(
|
||||
(widget) => widget.name === '$$canvas-image-preview'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.options?.canvasOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@ import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -108,7 +109,21 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
@@ -119,22 +134,12 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||
*/
|
||||
interface SharedWidgetEnhancements {
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts common widget enhancements shared across different rendering contexts.
|
||||
* This function centralizes the logic for extracting metadata from widgets.
|
||||
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): SharedWidgetEnhancements {
|
||||
@@ -199,6 +204,9 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = widget.options
|
||||
? {
|
||||
@@ -210,13 +218,13 @@ function safeWidgetMapper(
|
||||
: undefined
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const localId = isProxyWidget(widget)
|
||||
? widget._overlay?.nodeId
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? widget.sourceNodeId
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const name = isProxyWidget(widget)
|
||||
? widget._overlay.widgetName
|
||||
const name = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
return {
|
||||
@@ -226,8 +234,10 @@ function safeWidgetMapper(
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
options,
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? { ...options, canvasOnly: true }
|
||||
: options,
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -249,15 +259,40 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
node,
|
||||
'widgets'
|
||||
)
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
if (existingWidgetsDescriptor?.get) {
|
||||
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
|
||||
// Preserve it but sync results into a reactive array for Vue.
|
||||
const originalGetter = existingWidgetsDescriptor.get
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
const current: IBaseWidget[] = originalGetter.call(node) ?? []
|
||||
if (
|
||||
current.length !== reactiveWidgets.length ||
|
||||
current.some((w, i) => w !== reactiveWidgets[i])
|
||||
) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...current)
|
||||
}
|
||||
return reactiveWidgets
|
||||
},
|
||||
set: existingWidgetsDescriptor.set ?? (() => {}),
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
} else {
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
}
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
|
||||
68
src/composables/node/useNodeCanvasImagePreview.test.ts
Normal file
68
src/composables/node/useNodeCanvasImagePreview.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { useNodeCanvasImagePreview } from './useNodeCanvasImagePreview'
|
||||
|
||||
const imagePreviewWidget = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget',
|
||||
() => ({
|
||||
useImagePreviewWidget: () => imagePreviewWidget
|
||||
})
|
||||
)
|
||||
|
||||
describe('useNodeCanvasImagePreview', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not add preview widget when node has no images', () => {
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
useNodeCanvasImagePreview().showCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds preview widget for regular nodes with images', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.imgs = [new Image()]
|
||||
|
||||
useNodeCanvasImagePreview().showCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).toHaveBeenCalledTimes(1)
|
||||
expect(imagePreviewWidget).toHaveBeenCalledWith(node, {
|
||||
type: 'IMAGE_PREVIEW',
|
||||
name: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add duplicate preview widget when already present', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.imgs = [new Image()]
|
||||
node.addWidget('text', '$$canvas-image-preview', '', () => undefined, {})
|
||||
|
||||
useNodeCanvasImagePreview().showCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not add preview widget directly on SubgraphNode', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.imgs = [new Image()]
|
||||
|
||||
useNodeCanvasImagePreview().showCanvasImagePreview(subgraphNode)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
|
||||
|
||||
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
|
||||
|
||||
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
|
||||
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling canvas image previews in nodes
|
||||
@@ -15,6 +20,8 @@ export function useNodeCanvasImagePreview() {
|
||||
function showCanvasImagePreview(node: LGraphNode) {
|
||||
if (!node.imgs?.length) return
|
||||
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
if (!node.widgets?.find((w) => w.name === CANVAS_IMAGE_PREVIEW_WIDGET)) {
|
||||
imagePreviewWidget(node, {
|
||||
type: 'IMAGE_PREVIEW',
|
||||
|
||||
262
src/composables/node/usePromotedPreviews.test.ts
Normal file
262
src/composables/node/usePromotedPreviews.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
'nodeOutputs' | 'getNodeImageUrls'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
||||
)
|
||||
const useNodeOutputStoreMock = vi.hoisted(() =>
|
||||
vi.fn<() => MockNodeOutputStore>()
|
||||
)
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => {
|
||||
return {
|
||||
useNodeOutputStore: useNodeOutputStoreMock
|
||||
}
|
||||
})
|
||||
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
getNodeImageUrls
|
||||
}
|
||||
}
|
||||
|
||||
function createSetup() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
return { subgraph, subgraphNode }
|
||||
}
|
||||
|
||||
function addInteriorNode(
|
||||
setup: ReturnType<typeof createSetup>,
|
||||
options: {
|
||||
id: number
|
||||
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
|
||||
} = { id: 10 }
|
||||
): LGraphNode {
|
||||
const node = new LGraphNode('test')
|
||||
node.id = options.id
|
||||
if (options.previewMediaType) {
|
||||
node.previewMediaType = options.previewMediaType
|
||||
}
|
||||
setup.subgraph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
|
||||
const store = useNodeOutputStore()
|
||||
for (const nodeId of nodeIds) {
|
||||
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
||||
store.nodeOutputs[locatorId] = {
|
||||
images: [{ filename: 'output.png' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
})
|
||||
|
||||
it('returns empty array for non-SubgraphNode', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const { promotedPreviews } = usePromotedPreviews(() => node)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for null node', () => {
|
||||
const { promotedPreviews } = usePromotedPreviews(() => null)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'seed'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns image preview for promoted $$ widget with outputs', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
interiorNodeId: '10',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns video type when interior node has video previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
|
||||
'/view?filename=output.webm'
|
||||
])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('video')
|
||||
})
|
||||
|
||||
it('returns audio type when interior node has audio previewMediaType', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
|
||||
'/view?filename=output.mp3'
|
||||
])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('audio')
|
||||
})
|
||||
|
||||
it('returns separate entries for multiple promoted $$ widgets', () => {
|
||||
const setup = createSetup()
|
||||
const node10 = addInteriorNode(setup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
const node20 = addInteriorNode(setup, {
|
||||
id: 20,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'20',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10, 20])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(2)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(['/view?a=1'])
|
||||
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
|
||||
})
|
||||
|
||||
it('skips interior nodes with no image output', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('skips missing interior nodes', () => {
|
||||
const setup = createSetup()
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'99',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores non-$$ promoted widgets', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'seed'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
'10',
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
||||
})
|
||||
})
|
||||
75
src/composables/node/usePromotedPreviews.ts
Normal file
75
src/composables/node/usePromotedPreviews.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns reactive preview media from promoted `$$` pseudo-widgets
|
||||
* on a SubgraphNode. Each promoted preview interior node produces
|
||||
* a separate entry so they render independently.
|
||||
*/
|
||||
export function usePromotedPreviews(
|
||||
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const promotionStore = usePromotionStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
|
||||
if (!pseudoEntries.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from the reactive nodeOutputs ref to establish Vue
|
||||
// dependency tracking. getNodeImageUrls reads from the
|
||||
// non-reactive app.nodeOutputs, so without this access the
|
||||
// computed would never re-evaluate when outputs change.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.interiorNodeId
|
||||
)
|
||||
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
if (!_reactiveOutputs?.images?.length) continue
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
const type =
|
||||
interiorNode.previewMediaType === 'video'
|
||||
? 'video'
|
||||
: interiorNode.previewMediaType === 'audio'
|
||||
? 'audio'
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
interiorNodeId: entry.interiorNodeId,
|
||||
widgetName: entry.widgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
}
|
||||
|
||||
return previews
|
||||
})
|
||||
|
||||
return { promotedPreviews }
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
} from '@/constants/coreColorPalettes'
|
||||
|
||||
import { tryToggleWidgetPromotion } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { tryToggleWidgetPromotion } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Creates a getter/setter pair that transforms values on access if they have changed.
|
||||
* Does not observe deep changes.
|
||||
*
|
||||
* @example
|
||||
* const transformFunction = (items: string[]) => items.map(item => `root/${item}.ext`)
|
||||
* const { get, set } = useValueTransform(transformFunction, [])
|
||||
* Object.defineProperty(obj, 'value', { get, set })
|
||||
*
|
||||
* obj.value = ['filename1', 'filename2']
|
||||
* console.log(obj.value) // ["root/filename1.ext", "root/filename2.ext"]
|
||||
*/
|
||||
export function useValueTransform<Internal, External>(
|
||||
transform: (value: Internal) => External,
|
||||
initialValue: Internal
|
||||
) {
|
||||
let internalValue: Internal = initialValue
|
||||
let cachedValue: External = transform(initialValue)
|
||||
let isChanged = false
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!isChanged) return cachedValue
|
||||
cachedValue = transform(internalValue)
|
||||
return cachedValue
|
||||
},
|
||||
set: (value: Internal) => {
|
||||
isChanged = true
|
||||
internalValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/core/graph/subgraph/promotedWidgetTypes.ts
Normal file
14
src/core/graph/subgraph/promotedWidgetTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
widget: IBaseWidget
|
||||
): widget is PromotedWidgetView {
|
||||
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
|
||||
}
|
||||
921
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
921
src/core/graph/subgraph/promotedWidgetView.test.ts
Normal file
@@ -0,0 +1,921 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
const mockDomWidgetStore = vi.hoisted(() => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => mockDomWidgetStore
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes: LGraphNode[] = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
}
|
||||
|
||||
function setPromotions(
|
||||
subgraphNode: SubgraphNode,
|
||||
entries: [string, string][]
|
||||
) {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
entries.map(([interiorNodeId, widgetName]) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
||||
const innerNode = innerNodes[0]
|
||||
if (!innerNode) throw new Error('Expected at least one inner node')
|
||||
return innerNode
|
||||
}
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockDomWidgetStore.widgetStates.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('exposes sourceNodeId and sourceWidgetName', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
|
||||
expect(view.sourceNodeId).toBe('42')
|
||||
expect(view.sourceWidgetName).toBe('myWidget')
|
||||
})
|
||||
|
||||
test('name defaults to widgetName when no displayName given', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
expect(view.name).toBe('myWidget')
|
||||
})
|
||||
|
||||
test('name uses displayName when provided', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'1',
|
||||
'myWidget',
|
||||
'Custom Label'
|
||||
)
|
||||
expect(view.name).toBe('Custom Label')
|
||||
})
|
||||
|
||||
test('node getter returns the subgraphNode', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
// node is defined via Object.defineProperty at runtime but not on the TS interface
|
||||
expect((view as unknown as Record<string, unknown>).node).toBe(subgraphNode)
|
||||
})
|
||||
|
||||
test('serialize is false', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
expect(view.serialize).toBe(false)
|
||||
})
|
||||
|
||||
test('computedDisabled is false and setter is a no-op', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
expect(view.computedDisabled).toBe(false)
|
||||
view.computedDisabled = true
|
||||
expect(view.computedDisabled).toBe(false)
|
||||
})
|
||||
|
||||
test('positional properties are writable and independent', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
|
||||
expect(view.y).toBe(0)
|
||||
|
||||
view.y = 100
|
||||
view.last_y = 90
|
||||
view.computedHeight = 30
|
||||
|
||||
expect(view.y).toBe(100)
|
||||
expect(view.last_y).toBe(90)
|
||||
expect(view.computedHeight).toBe(30)
|
||||
})
|
||||
|
||||
test('type delegates to interior widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
|
||||
values: ['a', 'b']
|
||||
})
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'picker'
|
||||
)
|
||||
expect(view.type).toBe('combo')
|
||||
})
|
||||
|
||||
test('type falls back to button when interior widget is missing', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
||||
expect(view.type).toBe('button')
|
||||
})
|
||||
|
||||
test('options delegates to interior widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const opts = { values: ['a', 'b'] as string[] }
|
||||
innerNode.addWidget('combo', 'picker', 'a', () => {}, opts)
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'picker'
|
||||
)
|
||||
expect(view.options).toBe(opts)
|
||||
})
|
||||
|
||||
test('options falls back to empty object when interior widget is missing', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
||||
expect(view.options).toEqual({})
|
||||
})
|
||||
|
||||
test('linkedWidgets delegates to interior widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const seedWidget = innerNode.addWidget('number', 'seed', 1, () => {})
|
||||
const controlWidget = innerNode.addWidget(
|
||||
'combo',
|
||||
'control_after_generate',
|
||||
'randomize',
|
||||
() => {},
|
||||
{
|
||||
values: ['fixed', 'increment', 'decrement', 'randomize']
|
||||
}
|
||||
)
|
||||
seedWidget.linkedWidgets = [controlWidget]
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(view.linkedWidgets).toBe(seedWidget.linkedWidgets)
|
||||
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
|
||||
})
|
||||
|
||||
test('value is store-backed via widgetValueStore', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
// Value should read from the store (which was populated by addWidget)
|
||||
expect(view.value).toBe('initial')
|
||||
|
||||
// Setting value through the view updates the store
|
||||
view.value = 'updated'
|
||||
expect(view.value).toBe('updated')
|
||||
|
||||
// The interior widget reads from the same store
|
||||
expect(innerNode.widgets![0].value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value falls back to interior widget when store entry is missing', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidgetShape = {
|
||||
name: 'myWidget',
|
||||
type: 'text',
|
||||
value: 'initial',
|
||||
options: {}
|
||||
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
|
||||
const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
expect(view.value).toBe('initial')
|
||||
view.value = 'updated'
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const store = useWidgetValueStore()
|
||||
const bareId = String(innerNode.id)
|
||||
|
||||
// No displayName → falls back to widgetName
|
||||
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
|
||||
// Store label is undefined → falls back to displayName/widgetName
|
||||
const state = store.getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
bareId as never,
|
||||
'myWidget'
|
||||
)
|
||||
state!.label = undefined
|
||||
expect(view1.label).toBe('myWidget')
|
||||
|
||||
// With displayName → falls back to displayName
|
||||
const view2 = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
bareId,
|
||||
'myWidget',
|
||||
'Custom'
|
||||
)
|
||||
expect(view2.label).toBe('Custom')
|
||||
})
|
||||
|
||||
test('callback forwards to interior widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const callbackSpy = vi.fn()
|
||||
innerNode.addWidget('text', 'myWidget', 'val', callbackSpy)
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
view.callback!('newVal')
|
||||
expect(callbackSpy).toHaveBeenCalled()
|
||||
expect(callbackSpy.mock.calls[0][0]).toBe('newVal')
|
||||
})
|
||||
|
||||
test('callback is safe when interior widget is missing', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(subgraphNode, '999', 'missing')
|
||||
expect(() => view.callback!('val')).not.toThrow()
|
||||
})
|
||||
|
||||
test('computeSize delegates to interior widget computeSize', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const widget = innerNode.addWidget('text', 'legacySize', 'val', () => {})
|
||||
const computeSize = vi.fn<(width?: number) => [number, number]>(
|
||||
(width?: number) => [width ?? 0, 37]
|
||||
)
|
||||
widget.computeSize = computeSize
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'legacySize'
|
||||
)
|
||||
|
||||
expect(typeof view.computeSize).toBe('function')
|
||||
expect(view.computeSize?.(210)).toEqual([210, 37])
|
||||
expect(computeSize).toHaveBeenCalledWith(210)
|
||||
})
|
||||
|
||||
test('onPointerDown falls back to legacy mouse callback', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
subgraphNode.pos = [10, 20]
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const mouse = vi.fn(() => true)
|
||||
const legacyWidget = {
|
||||
name: 'legacyMouse',
|
||||
type: 'mystery-legacy',
|
||||
value: 'val',
|
||||
options: {},
|
||||
mouse
|
||||
} as unknown as IBaseWidget
|
||||
innerNode.widgets = [legacyWidget]
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'legacyMouse'
|
||||
)
|
||||
|
||||
const pointer = new CanvasPointer(document.createElement('div'))
|
||||
pointer.eDown = {
|
||||
canvasX: 110,
|
||||
canvasY: 120,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
safeOffsetX: 0,
|
||||
safeOffsetY: 0
|
||||
} as CanvasPointerEvent
|
||||
|
||||
const handled = view.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
{} as Parameters<NonNullable<typeof view.onPointerDown>>[2]
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(mouse).toHaveBeenCalledWith(pointer.eDown, [100, 100], subgraphNode)
|
||||
|
||||
pointer.eUp = {
|
||||
canvasX: 130,
|
||||
canvasY: 140,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
safeOffsetX: 0,
|
||||
safeOffsetY: 0
|
||||
} as CanvasPointerEvent
|
||||
pointer.finally?.()
|
||||
|
||||
expect(mouse).toHaveBeenCalledWith(pointer.eUp, [120, 120], subgraphNode)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode.widgets getter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const first = subgraphNode.widgets[0]
|
||||
const second = subgraphNode.widgets[0]
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
test('memoizes promotion list by reference', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const views1 = subgraphNode.widgets
|
||||
expect(views1).toHaveLength(1)
|
||||
|
||||
// Same reference → same result (memoized)
|
||||
const views2 = subgraphNode.widgets
|
||||
expect(views2[0]).toBe(views1[0])
|
||||
|
||||
// New store value with same content → same cached view object
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
const views3 = subgraphNode.widgets
|
||||
expect(views3[0]).toBe(views1[0])
|
||||
})
|
||||
|
||||
test('cleans stale cache entries when promotions shrink', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
const viewA = subgraphNode.widgets[0]
|
||||
|
||||
// Remove widgetA from promotion list
|
||||
setPromotions(subgraphNode, [['1', 'widgetB']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
|
||||
// Re-adding widgetA creates a new view (old one was cleaned)
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
])
|
||||
const newViewA = subgraphNode.widgets[1]
|
||||
expect(newViewA).not.toBe(viewA)
|
||||
})
|
||||
|
||||
test('deduplicates entries with same nodeId:widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetA']
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('setter is a no-op', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
// Assigning to widgets does nothing
|
||||
subgraphNode.widgets = []
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('migrates legacy -1 entries via _resolveLegacyEntry', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Simulate a slot-connected widget so legacy resolution works
|
||||
const subgraph = subgraphNode.subgraph
|
||||
subgraph.addInput('stringWidget', '*')
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
|
||||
// The _internalConfigureAfterSlots would have set up the slot-connected
|
||||
// widget via _setWidget if there's a link. For unit testing legacy
|
||||
// migration, we need to set up the input._widget manually.
|
||||
const input = subgraphNode.inputs.find((i) => i.name === 'stringWidget')
|
||||
if (input) {
|
||||
input._widget = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNodes[0].id),
|
||||
'stringWidget'
|
||||
)
|
||||
}
|
||||
|
||||
// Set legacy -1 format via properties and re-run hydration
|
||||
subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']]
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
|
||||
// Migration should have rewritten the store with resolved IDs
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNodes[0].id),
|
||||
widgetName: 'stringWidget'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('hydrate promotions from serialize/configure round-trip', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
const restoredNode = createTestSubgraphNode(subgraphNode.subgraph, {
|
||||
id: 99
|
||||
})
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraphNode.subgraph.id
|
||||
})
|
||||
|
||||
const restoredEntries = usePromotionStore().getPromotions(
|
||||
restoredNode.rootGraph.id,
|
||||
restoredNode.id
|
||||
)
|
||||
expect(restoredEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [[String(innerNode.id), 'widgetA']])
|
||||
|
||||
const createNodeSpy = vi
|
||||
.spyOn(LiteGraph, 'createNode')
|
||||
.mockImplementation(() =>
|
||||
createTestSubgraphNode(subgraphNode.subgraph, { id: 999 })
|
||||
)
|
||||
|
||||
const clonedNode = subgraphNode.clone()
|
||||
expect(clonedNode).toBeTruthy()
|
||||
createNodeSpy.mockRestore()
|
||||
if (!clonedNode) throw new Error('Expected clone to return a node')
|
||||
|
||||
const clonedSerialized = clonedNode.serialize()
|
||||
expect(clonedSerialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[String(innerNode.id), 'widgetA']
|
||||
])
|
||||
|
||||
const hydratedClone = createTestSubgraphNode(subgraphNode.subgraph, {
|
||||
id: 100
|
||||
})
|
||||
hydratedClone.configure({
|
||||
...clonedSerialized,
|
||||
id: hydratedClone.id,
|
||||
type: subgraphNode.subgraph.id
|
||||
})
|
||||
|
||||
const hydratedEntries = usePromotionStore().getPromotions(
|
||||
hydratedClone.rootGraph.id,
|
||||
hydratedClone.id
|
||||
)
|
||||
expect(hydratedEntries).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(innerNode.id),
|
||||
widgetName: 'widgetA'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('widgets getter caching', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
|
||||
const [viewA, viewB] = subgraphNode.widgets
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
])
|
||||
|
||||
expect(subgraphNode.widgets[0]).toBe(viewB)
|
||||
expect(subgraphNode.widgets[1]).toBe(viewA)
|
||||
})
|
||||
|
||||
test('deduplicates by key while preserving first-occurrence order', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB'],
|
||||
['1', 'widgetA']
|
||||
])
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('returns same array reference when promotions unchanged', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
const result2 = subgraphNode.widgets
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
|
||||
test('returns new array after promotion change', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
const result2 = subgraphNode.widgets
|
||||
|
||||
expect(result1).not.toBe(result2)
|
||||
expect(result2).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('invalidates cache on removeWidget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
|
||||
const result1 = subgraphNode.widgets
|
||||
expect(result1).toHaveLength(2)
|
||||
|
||||
subgraphNode.removeWidget(result1[0])
|
||||
const result2 = subgraphNode.widgets
|
||||
expect(result2).toHaveLength(1)
|
||||
expect(result1).not.toBe(result2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promote/demote cycle', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('promoting adds to store and widgets reflects it', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
const view = subgraphNode.widgets[0] as PromotedWidgetView
|
||||
expect(view.sourceNodeId).toBe('1')
|
||||
expect(view.sourceWidgetName).toBe('widgetA')
|
||||
})
|
||||
|
||||
test('demoting via removeWidget removes from store', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
|
||||
const viewA = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(viewA)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toStrictEqual([
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('full promote → demote → re-promote cycle', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
// Promote
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
const view1 = subgraphNode.widgets[0]
|
||||
|
||||
// Demote
|
||||
subgraphNode.removeWidget(view1)
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote — creates a new view since the cache was cleared
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0]).not.toBe(view1)
|
||||
expect(
|
||||
(subgraphNode.widgets[0] as PromotedWidgetView).sourceWidgetName
|
||||
).toBe('widgetA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnected state', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('view resolves type when interior widget exists', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
|
||||
setPromotions(subgraphNode, [['1', 'numWidget']])
|
||||
|
||||
expect(subgraphNode.widgets[0].type).toBe('number')
|
||||
})
|
||||
|
||||
test('view falls back to button type when interior node is removed', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'myWidget']])
|
||||
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
|
||||
// Remove the interior node from the subgraph
|
||||
subgraphNode.subgraph.remove(innerNodes[0])
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
})
|
||||
|
||||
test('view recovers when interior widget is re-added', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'myWidget']])
|
||||
|
||||
// Remove widget
|
||||
innerNodes[0].widgets!.pop()
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
|
||||
// Re-add widget
|
||||
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('options returns empty object when disconnected', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
setPromotions(subgraphNode, [['999', 'ghost']])
|
||||
expect(subgraphNode.widgets[0].options).toEqual({})
|
||||
})
|
||||
|
||||
test('tooltip returns undefined when disconnected', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
setPromotions(subgraphNode, [['999', 'ghost']])
|
||||
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function createFakeCanvasContext() {
|
||||
return new Proxy({} as CanvasRenderingContext2D, {
|
||||
get: () => vi.fn(() => ({ width: 10 }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('DOM widget promotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function createMockDOMWidget(node: LGraphNode, name: string) {
|
||||
const widget = node.addWidget('text', name, 'val', () => {})
|
||||
// Add 'element' and 'id' to make it a BaseDOMWidget
|
||||
Object.defineProperties(widget, {
|
||||
element: { value: document.createElement('div'), enumerable: true },
|
||||
id: { value: `dom-widget-${name}`, enumerable: true }
|
||||
})
|
||||
return widget
|
||||
}
|
||||
|
||||
function createMockComponentWidget(node: LGraphNode, name: string) {
|
||||
const widget = node.addWidget('custom', name, 'val', () => {})
|
||||
Object.defineProperties(widget, {
|
||||
component: { value: {}, enumerable: true },
|
||||
id: { value: `comp-widget-${name}`, enumerable: true }
|
||||
})
|
||||
return widget
|
||||
}
|
||||
|
||||
test('draw registers position override for DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
||||
|
||||
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
|
||||
'dom-widget-textarea',
|
||||
{ node: subgraphNode, widget: view }
|
||||
)
|
||||
})
|
||||
|
||||
test('draw registers position override for component widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
createMockComponentWidget(innerNodes[0], 'compWidget')
|
||||
setPromotions(subgraphNode, [['1', 'compWidget']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
||||
|
||||
expect(mockDomWidgetStore.setPositionOverride).toHaveBeenCalledWith(
|
||||
'comp-widget-compWidget',
|
||||
{ node: subgraphNode, widget: view }
|
||||
)
|
||||
})
|
||||
|
||||
test('draw does not register override for non-DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'textWidget']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
|
||||
|
||||
expect(mockDomWidgetStore.setPositionOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('draw does not mutate interior node pos or size for non-DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const interiorNode = firstInnerNode(innerNodes)
|
||||
interiorNode.pos = [10, 20]
|
||||
interiorNode.size = [300, 120]
|
||||
interiorNode.addWidget('text', 'textWidget', 'val', () => {})
|
||||
setPromotions(subgraphNode, [[String(interiorNode.id), 'textWidget']])
|
||||
|
||||
const originalPos = [...interiorNode.pos]
|
||||
const originalSize = [...interiorNode.size]
|
||||
const view = subgraphNode.widgets[0]
|
||||
|
||||
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
|
||||
|
||||
expect(Array.from(interiorNode.pos)).toEqual(originalPos)
|
||||
expect(Array.from(interiorNode.size)).toEqual(originalSize)
|
||||
})
|
||||
|
||||
test('computeLayoutSize delegates to interior DOM widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
domWidget.computeLayoutSize = vi.fn(() => ({
|
||||
minHeight: 100,
|
||||
maxHeight: 300,
|
||||
minWidth: 0
|
||||
}))
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
const result = view.computeLayoutSize!(subgraphNode)
|
||||
|
||||
expect(result).toEqual({ minHeight: 100, maxHeight: 300, minWidth: 0 })
|
||||
})
|
||||
|
||||
test('demoting clears position override for DOM widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'textarea')
|
||||
setPromotions(subgraphNode, [['1', 'textarea']])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(view)
|
||||
|
||||
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
||||
'dom-widget-textarea'
|
||||
)
|
||||
})
|
||||
|
||||
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
createMockDOMWidget(innerNodes[0], 'widgetA')
|
||||
createMockDOMWidget(innerNodes[0], 'widgetB')
|
||||
setPromotions(subgraphNode, [
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
|
||||
// Access widgets to populate cache
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
subgraphNode.onRemoved()
|
||||
|
||||
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
||||
'dom-widget-widgetA'
|
||||
)
|
||||
expect(mockDomWidgetStore.clearPositionOverride).toHaveBeenCalledWith(
|
||||
'dom-widget-widgetB'
|
||||
)
|
||||
})
|
||||
})
|
||||
367
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
367
src/core/graph/subgraph/promotedWidgetView.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
function resolve(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
const node = subgraphNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
const widget = node.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
||||
return widget ? { node, widget } : undefined
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
|
||||
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
return 'mouse' in widget && typeof widget.mouse === 'function'
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
|
||||
}
|
||||
|
||||
class PromotedWidgetView implements IPromotedWidgetView {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
private readonly graphId: string
|
||||
private readonly bareNodeId: NodeId
|
||||
private yValue = 0
|
||||
|
||||
private projectedSourceNode?: LGraphNode
|
||||
private projectedSourceWidget?: IBaseWidget
|
||||
private projectedWidget?: BaseWidget
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
this.bareNodeId = stripGraphPrefix(nodeId)
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
return this.subgraphNode
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.displayName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
set y(value: number) {
|
||||
this.yValue = value
|
||||
this.syncDomOverride()
|
||||
}
|
||||
|
||||
get computedDisabled(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
set computedDisabled(_value: boolean | undefined) {}
|
||||
|
||||
get type(): IBaseWidget['type'] {
|
||||
return this.resolve()?.widget.type ?? 'button'
|
||||
}
|
||||
|
||||
get options(): IBaseWidget['options'] {
|
||||
return this.resolve()?.widget.options ?? {}
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this.resolve()?.widget.tooltip
|
||||
}
|
||||
|
||||
get linkedWidgets(): IBaseWidget[] | undefined {
|
||||
return this.resolve()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolve()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolve()
|
||||
if (resolved && isWidgetValue(value)) {
|
||||
resolved.widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolve()?.widget.hidden ?? false
|
||||
}
|
||||
|
||||
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
|
||||
const resolved = this.resolve()
|
||||
const computeLayoutSize = resolved?.widget.computeLayoutSize
|
||||
if (!computeLayoutSize) return undefined
|
||||
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
|
||||
}
|
||||
|
||||
get computeSize(): IBaseWidget['computeSize'] {
|
||||
const resolved = this.resolve()
|
||||
const computeSize = resolved?.widget.computeSize
|
||||
if (!computeSize) return undefined
|
||||
return (width?: number) => computeSize.call(resolved.widget, width)
|
||||
}
|
||||
|
||||
draw(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
widgetWidth: number,
|
||||
y: number,
|
||||
H: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
const resolved = this.resolve()
|
||||
if (!resolved) {
|
||||
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
|
||||
|
||||
const projected = this.getProjectedWidget(resolved)
|
||||
if (!projected || typeof projected.drawWidget !== 'function') return
|
||||
|
||||
const originalY = projected.y
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.value = this.value
|
||||
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true
|
||||
})
|
||||
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
pointer: CanvasPointer,
|
||||
_node: LGraphNode,
|
||||
canvas: LGraphCanvas
|
||||
): boolean {
|
||||
const resolved = this.resolve()
|
||||
if (!resolved) return false
|
||||
|
||||
const interior = resolved.widget
|
||||
if (typeof interior.onPointerDown === 'function') {
|
||||
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
|
||||
if (handled) return true
|
||||
}
|
||||
|
||||
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
|
||||
if (concrete)
|
||||
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
|
||||
|
||||
if (hasLegacyMouse(interior))
|
||||
return this.handleLegacyMouse(pointer, interior)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
callback(
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
e?: CanvasPointerEvent
|
||||
) {
|
||||
this.resolve()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
private resolve(): { node: LGraphNode; widget: IBaseWidget } | undefined {
|
||||
return resolve(this.subgraphNode, this.sourceNodeId, this.sourceWidgetName)
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
this.bareNodeId,
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}): BaseWidget | undefined {
|
||||
const shouldRebuild =
|
||||
!this.projectedWidget ||
|
||||
this.projectedSourceNode !== resolved.node ||
|
||||
this.projectedSourceWidget !== resolved.widget
|
||||
|
||||
if (!shouldRebuild) return this.projectedWidget
|
||||
|
||||
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
|
||||
if (!concrete) {
|
||||
this.projectedWidget = undefined
|
||||
this.projectedSourceNode = undefined
|
||||
this.projectedSourceWidget = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
|
||||
this.projectedSourceNode = resolved.node
|
||||
this.projectedSourceWidget = resolved.widget
|
||||
return this.projectedWidget
|
||||
}
|
||||
|
||||
private bindConcretePointerHandlers(
|
||||
pointer: CanvasPointer,
|
||||
canvas: LGraphCanvas,
|
||||
concrete: BaseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
pointer.onClick = () =>
|
||||
concrete.onClick({
|
||||
e: downEvent,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
pointer.onDrag = (eMove) =>
|
||||
concrete.onDrag?.({
|
||||
e: eMove,
|
||||
node: this.subgraphNode,
|
||||
canvas
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private handleLegacyMouse(
|
||||
pointer: CanvasPointer,
|
||||
interior: LegacyMouseWidget
|
||||
): boolean {
|
||||
const downEvent = pointer.eDown
|
||||
if (!downEvent) return false
|
||||
|
||||
const downPosition: Point = [
|
||||
downEvent.canvasX - this.subgraphNode.pos[0],
|
||||
downEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(downEvent, downPosition, this.subgraphNode)
|
||||
|
||||
pointer.finally = () => {
|
||||
const upEvent = pointer.eUp
|
||||
if (!upEvent) return
|
||||
|
||||
const upPosition: Point = [
|
||||
upEvent.canvasX - this.subgraphNode.pos[0],
|
||||
upEvent.canvasY - this.subgraphNode.pos[1]
|
||||
]
|
||||
interior.mouse(upEvent, upPosition, this.subgraphNode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private syncDomOverride(
|
||||
resolved:
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined = this.resolve()
|
||||
) {
|
||||
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
|
||||
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
|
||||
node: this.subgraphNode,
|
||||
widget: this
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
return 'id' in widget && ('element' in widget || 'component' in widget)
|
||||
}
|
||||
|
||||
function drawDisconnectedPlaceholder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
H: number
|
||||
) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.fillRect(15, y, width - 30, H)
|
||||
ctx.fillStyle = '#999'
|
||||
ctx.font = '11px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
|
||||
ctx.restore()
|
||||
}
|
||||
235
src/core/graph/subgraph/promotionUtils.test.ts
Normal file
235
src/core/graph/subgraph/promotionUtils.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
overrides: Partial<
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return { name: 'widget', ...overrides } as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns true for $$-prefixed widget names', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
|
||||
).toBe(true)
|
||||
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "preview"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: false, type: 'preview' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({
|
||||
name: 'videopreview',
|
||||
type: 'preview',
|
||||
options: { serialize: false }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "video"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'vid', serialize: false, type: 'video' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for serialize:false with type "audioUI"', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'audio', serialize: false, type: 'audioUI' })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for type "preview" when serialize is not false', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'videopreview', serialize: true, type: 'preview' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular widgets', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for serialize:false with unknown type', () => {
|
||||
expect(
|
||||
isPreviewPseudoWidget(
|
||||
widget({ name: 'text', serialize: false, type: 'customtext' })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneDisconnected', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('removes disconnected entries and emits a dev warning', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
subgraphNode.subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'kept', 'value', () => {})
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
|
||||
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
|
||||
{ interiorNodeId: '9999', widgetName: 'missing-node' }
|
||||
])
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
pruneDisconnected(subgraphNode)
|
||||
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('PreviewImage')
|
||||
interiorNode.type = 'PreviewImage'
|
||||
subgraphNode.subgraph.add(interiorNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
|
||||
pruneDisconnected(subgraphNode)
|
||||
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toEqual([
|
||||
{
|
||||
interiorNodeId: String(interiorNode.id),
|
||||
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPromotableWidgets', () => {
|
||||
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
|
||||
const node = new LGraphNode('PreviewImage')
|
||||
node.type = 'PreviewImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds virtual canvas preview widget for SaveImage nodes', () => {
|
||||
const node = new LGraphNode('SaveImage')
|
||||
node.type = 'SaveImage'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for non-image nodes', () => {
|
||||
const node = new LGraphNode('TextNode')
|
||||
node.addOutput('TEXT', 'STRING')
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
|
||||
const node = new LGraphNode('ImageInvert')
|
||||
node.type = 'ImageInvert'
|
||||
|
||||
const widgets = getPromotableWidgets(node)
|
||||
|
||||
expect(
|
||||
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
updatePreviewsMock.mockReset()
|
||||
})
|
||||
|
||||
it('skips deferred updatePreviews when a preview widget already exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const previewWidget = interiorNode.addWidget(
|
||||
'custom',
|
||||
'videopreview',
|
||||
'value',
|
||||
() => {}
|
||||
)
|
||||
previewWidget.type = 'preview'
|
||||
previewWidget.serialize = false
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
285
src/core/graph/subgraph/promotionUtils.ts
Normal file
285
src/core/graph/subgraph/promotionUtils.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/useNodeCanvasImagePreview'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
/**
|
||||
* Returns true for pseudo-widgets that display media previews and should
|
||||
* be auto-promoted when their node is inside a subgraph.
|
||||
* Matches the core `$$` convention as well as custom-node patterns
|
||||
* (e.g. VHS `videopreview` with type `"preview"`).
|
||||
*/
|
||||
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
||||
if (widget.name.startsWith('$$')) return true
|
||||
// Custom nodes may set serialize on the widget or in options
|
||||
if (widget.serialize !== false && widget.options?.serialize !== false)
|
||||
return false
|
||||
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function promoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(
|
||||
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
)
|
||||
const widgetName = getWidgetName(widget)
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
const { navigationStack } = useSubgraphNavigationStore()
|
||||
const subgraph = navigationStack.at(-1)
|
||||
if (!subgraph) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
})
|
||||
return []
|
||||
}
|
||||
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
|
||||
return parentGraph.nodes.filter(
|
||||
(node): node is SubgraphNode =>
|
||||
node.type === subgraph.id && node.isSubgraphNode()
|
||||
)
|
||||
}
|
||||
|
||||
export function addWidgetPromotionOptions(
|
||||
options: (IContextMenuValue<unknown> | null)[],
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const parents = getParentNodes()
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
content: t('subgraphStore.promoteWidget', {
|
||||
name: widget.label ?? widget.name
|
||||
}),
|
||||
callback: () => {
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
})
|
||||
else {
|
||||
options.unshift({
|
||||
content: t('subgraphStore.unpromoteWidget', {
|
||||
name: widget.label ?? widget.name
|
||||
}),
|
||||
callback: () => {
|
||||
demoteWidget(node, widget, parents)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function tryToggleWidgetPromotion() {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const [x, y] = canvas.graph_mouse
|
||||
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
|
||||
if (!node) return
|
||||
const widget = node.getWidgetOnPos(x, y, true)
|
||||
const parents = getParentNodes()
|
||||
if (!parents.length || !widget) return
|
||||
const store = usePromotionStore()
|
||||
const nodeId = String(node.id)
|
||||
const widgetName = getWidgetName(widget)
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
else demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
|
||||
return supportsVirtualCanvasImagePreview(node)
|
||||
}
|
||||
|
||||
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
|
||||
return {
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'IMAGE_PREVIEW',
|
||||
options: { serialize: false },
|
||||
serialize: false,
|
||||
y: 0,
|
||||
computedDisabled: false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
|
||||
const widgets = [...(node.widgets ?? [])]
|
||||
|
||||
const hasCanvasPreviewWidget = widgets.some(
|
||||
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
)
|
||||
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
|
||||
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
|
||||
widgets.push(createVirtualCanvasImagePreviewWidget())
|
||||
}
|
||||
|
||||
return widgets
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
|
||||
}
|
||||
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const store = usePromotionStore()
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
node.updateComputedDisabled()
|
||||
|
||||
const hasPreviewWidget = () =>
|
||||
node.widgets?.some(isPreviewPseudoWidget) ?? false
|
||||
|
||||
function promotePreviewWidget() {
|
||||
const widget = node.widgets?.find(isPreviewPseudoWidget)
|
||||
if (!widget) return
|
||||
if (
|
||||
store.isPromoted(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(node.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
// Promote preview widgets that already exist (e.g. custom node DOM widgets
|
||||
// like VHS videopreview that are created in onNodeCreated).
|
||||
promotePreviewWidget()
|
||||
|
||||
// If a preview widget already exists in this frame, there's nothing to
|
||||
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
|
||||
if (hasPreviewWidget()) continue
|
||||
|
||||
// Also schedule a deferred check: core $$ widgets are created lazily by
|
||||
// updatePreviews when node outputs are first loaded.
|
||||
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
|
||||
}
|
||||
const filteredWidgets: WidgetItem[] = interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
.filter(isRecommendedWidget)
|
||||
for (const [n, w] of filteredWidgets) {
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(n.id),
|
||||
getWidgetName(w)
|
||||
)
|
||||
}
|
||||
subgraphNode.computeSize(subgraphNode.size)
|
||||
}
|
||||
|
||||
export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
const store = usePromotionStore()
|
||||
const subgraph = subgraphNode.subgraph
|
||||
const entries = store.getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
|
||||
[]
|
||||
|
||||
const validEntries = entries.filter((entry) => {
|
||||
const node = subgraph.getNodeById(entry.interiorNodeId)
|
||||
if (!node) {
|
||||
removedEntries.push(entry)
|
||||
return false
|
||||
}
|
||||
const hasWidget = getPromotableWidgets(node).some(
|
||||
(iw) => iw.name === entry.widgetName
|
||||
)
|
||||
if (!hasWidget) {
|
||||
removedEntries.push(entry)
|
||||
}
|
||||
return hasWidget
|
||||
})
|
||||
|
||||
if (removedEntries.length > 0 && import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[proxyWidgetUtils] Pruned disconnected promotions',
|
||||
removedEntries,
|
||||
{
|
||||
graphId: subgraphNode.rootGraph.id,
|
||||
subgraphNodeId: subgraphNode.id
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
const canvasEl: Partial<HTMLCanvasElement> = { addEventListener() {} }
|
||||
const canvas: Partial<LGraphCanvas> = { canvas: canvasEl as HTMLCanvasElement }
|
||||
registerProxyWidgets(canvas as LGraphCanvas)
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
|
||||
['1', 'stringWidget']
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [
|
||||
['1', 'stringWidget'],
|
||||
['2', 'stringWidget']
|
||||
]
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).not.toEqual(
|
||||
subgraphNode.widgets[1].name
|
||||
)
|
||||
})
|
||||
test('Will serialize existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'istringWidget', 'value', () => {})
|
||||
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
const proxyWidgets = parseProxyWidgets(subgraphNode.properties.proxyWidgets)
|
||||
proxyWidgets.push(['1', 'istringWidget'])
|
||||
subgraphNode.properties.proxyWidgets = proxyWidgets
|
||||
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
|
||||
subgraphNode.properties.proxyWidgets = [proxyWidgets[1], proxyWidgets[0]]
|
||||
expect(subgraphNode.widgets[0].name).toBe('1: istringWidget')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
innerNodes[0].widgets[0].last_y = 11
|
||||
innerNodes[0].widgets[0].computedHeight = 12
|
||||
subgraphNode.widgets[0].y = 20
|
||||
subgraphNode.widgets[0].last_y = 21
|
||||
subgraphNode.widgets[0].computedHeight = 22
|
||||
expect(innerNodes[0].widgets[0].y).toBe(10)
|
||||
expect(innerNodes[0].widgets[0].last_y).toBe(11)
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Can detach and re-attach widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
const poppedWidget = innerNodes[0].widgets.pop()
|
||||
//simulate new draw frame
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe(undefined)
|
||||
innerNodes[0].widgets.push(poppedWidget!)
|
||||
subgraphNode.widgets[0].computedHeight = 10
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
expect(proxyWidget.name).toBe('1: seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label reflects linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
expect(proxyWidget.label).toBe('seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'My Inner Label'
|
||||
// Trigger re-resolve of linked widget
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget user rename takes priority over linked widget label', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'My Custom Seed'
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
|
||||
innerNodes[0].widgets![0].label = 'Inner Override'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('My Custom Seed')
|
||||
})
|
||||
|
||||
test('Proxy widget label resets to linked widget on undefined', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
|
||||
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
|
||||
const proxyWidget = subgraphNode.widgets[0]
|
||||
proxyWidget.label = 'Custom'
|
||||
expect(proxyWidget.label).toBe('Custom')
|
||||
|
||||
proxyWidget.label = undefined
|
||||
innerNodes[0].widgets![0].label = 'Inner Label'
|
||||
proxyWidget.computedHeight = 10
|
||||
expect(proxyWidget.label).toBe('Inner Label')
|
||||
})
|
||||
|
||||
test('Proxy widget labels are correct when loaded from serialized data', () => {
|
||||
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
|
||||
// to exercise the deserialization/onConfigure path from blueprint JSON.
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(innerNode)
|
||||
innerNode.addWidget('text', 'seed', 'value', () => {})
|
||||
innerNode.addWidget('text', 'steps', 'value', () => {})
|
||||
|
||||
const parentGraph = new LGraph()
|
||||
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
|
||||
id: 1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['1', 'seed'],
|
||||
['1', 'steps']
|
||||
]
|
||||
},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
expect(subgraphNode.widgets[0].label).toBe('seed')
|
||||
expect(subgraphNode.widgets[0].name).toBe('1: seed')
|
||||
expect(subgraphNode.widgets[1].label).toBe('steps')
|
||||
expect(subgraphNode.widgets[1].name).toBe('1: steps')
|
||||
})
|
||||
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
const widget = innerNodes[0].widgets![0]
|
||||
|
||||
// Promote once
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
promoteWidget(innerNodes[0], widget, [subgraphNode])
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toHaveLength(1)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
|
||||
['1', 'stringWidget']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,257 +0,0 @@
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteRecommendedWidgets
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* @typedef {object} Overlay - Each proxy Widget has an associated overlay object
|
||||
* Accessing a property which exists in the overlay object will
|
||||
* instead result in the action being performed on the overlay object
|
||||
* 3 properties are added for locating the proxied widget
|
||||
* @property {LGraph} graph - The graph the widget resides in. Used for widget lookup
|
||||
* @property {string} nodeId - The NodeId the proxy Widget is located on
|
||||
* @property {string} widgetName - The name of the linked widget
|
||||
*
|
||||
* @property {boolean} isProxyWidget - Always true, used as type guard
|
||||
* @property {LGraphNode} node - not included on IBaseWidget, but required for overlay
|
||||
*/
|
||||
type Overlay = Partial<IBaseWidget> & {
|
||||
graph: LGraph
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
isProxyWidget: boolean
|
||||
node?: LGraphNode
|
||||
}
|
||||
// A ProxyWidget can be treated like a normal widget.
|
||||
// the _overlay property can be used to directly access the Overlay object
|
||||
/**
|
||||
* @typedef {object} ProxyWidget - a reference to a widget that can
|
||||
* be displayed and owned by a separate node
|
||||
* @property {Overlay} _overlay - a special property to access the overlay of the widget
|
||||
* Any property that exists in the overlay will be accessed instead of the property
|
||||
* on the linked widget
|
||||
*/
|
||||
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
|
||||
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
|
||||
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
|
||||
}
|
||||
export function isDisconnectedWidget(w: ProxyWidget) {
|
||||
return w instanceof disconnectedWidget.constructor
|
||||
}
|
||||
|
||||
export function registerProxyWidgets(canvas: LGraphCanvas) {
|
||||
//NOTE: canvasStore hasn't been initialized yet
|
||||
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
|
||||
const { subgraph, fromNode } = e.detail
|
||||
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
|
||||
for (const node of subgraph.nodes) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
widget.promoted = proxyWidgets.some(
|
||||
([n, w]) => node.id == n && widget.name == w
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
canvas.canvas.addEventListener<'subgraph-converted'>(
|
||||
'subgraph-converted',
|
||||
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
|
||||
)
|
||||
SubgraphNode.prototype.onConfigure = onConfigure
|
||||
}
|
||||
|
||||
const originalOnConfigure = SubgraphNode.prototype.onConfigure
|
||||
const onConfigure = function (
|
||||
this: LGraphNode,
|
||||
serialisedNode: ISerialisedNode
|
||||
) {
|
||||
if (!this.isSubgraphNode())
|
||||
throw new Error("Can't add proxyWidgets to non-subgraphNode")
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
//Must give value to proxyWidgets prior to defining or it won't serialize
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
originalOnConfigure?.call(this, serialisedNode)
|
||||
|
||||
Object.defineProperty(this.properties, 'proxyWidgets', {
|
||||
get: () =>
|
||||
this.widgets.map((w) =>
|
||||
isProxyWidget(w)
|
||||
? [w._overlay.nodeId, w._overlay.widgetName]
|
||||
: ['-1', w.name]
|
||||
),
|
||||
set: (property: NodeProperty) => {
|
||||
const parsed = parseProxyWidgets(property)
|
||||
const { deactivateWidget, setWidget } = useDomWidgetStore()
|
||||
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
|
||||
if (isActiveGraph) {
|
||||
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
|
||||
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
|
||||
}
|
||||
}
|
||||
|
||||
const newWidgets = parsed.flatMap(([nodeId, widgetName]) => {
|
||||
if (nodeId === '-1') {
|
||||
const widget = this.widgets.find((w) => w.name === widgetName)
|
||||
return widget ? [widget] : []
|
||||
}
|
||||
const w = newProxyWidget(this, nodeId, widgetName)
|
||||
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
|
||||
return [w]
|
||||
})
|
||||
this.widgets = this.widgets.filter((w) => {
|
||||
if (isProxyWidget(w)) return false
|
||||
const widgetName = w.name
|
||||
return !parsed.some(([, name]) => widgetName === name)
|
||||
})
|
||||
this.widgets.push(...newWidgets)
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
this._setConcreteSlots()
|
||||
this.arrange()
|
||||
}
|
||||
})
|
||||
if (serialisedNode.properties?.proxyWidgets) {
|
||||
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
|
||||
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
|
||||
serialisedNode.widgets_values?.forEach((v, index) => {
|
||||
if (parsed[index]?.[0] !== '-1') return
|
||||
const widget = this.widgets.find((w) => w.name == parsed[index][1])
|
||||
if (v !== null && widget) widget.value = v
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function newProxyWidget(
|
||||
subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
) {
|
||||
const name = `${nodeId}: ${widgetName}`
|
||||
const overlay = {
|
||||
//items specific for proxy management
|
||||
nodeId,
|
||||
graph: subgraphNode.subgraph,
|
||||
widgetName,
|
||||
//Items which normally exist on widgets
|
||||
afterQueued: undefined,
|
||||
computedHeight: undefined,
|
||||
isProxyWidget: true,
|
||||
last_y: undefined,
|
||||
name,
|
||||
node: subgraphNode,
|
||||
onRemove: undefined,
|
||||
promoted: undefined,
|
||||
serialize: false,
|
||||
width: undefined,
|
||||
y: 0
|
||||
}
|
||||
return newProxyFromOverlay(subgraphNode, overlay)
|
||||
}
|
||||
function resolveLinkedWidget(
|
||||
overlay: Overlay
|
||||
): [LGraphNode | undefined, IBaseWidget | undefined] {
|
||||
const { graph, nodeId, widgetName } = overlay
|
||||
const n = getNodeByExecutionId(graph, nodeId)
|
||||
if (!n) return [undefined, undefined]
|
||||
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
|
||||
//Slightly hacky. Force recursive resolution of nested widgets
|
||||
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
||||
widget.computedHeight = 20
|
||||
return [n, widget]
|
||||
}
|
||||
|
||||
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
let backingWidget = linkedWidget ?? disconnectedWidget
|
||||
if (overlay.widgetName.startsWith('$$')) {
|
||||
overlay.node = new Proxy(subgraphNode, {
|
||||
get(_t, p) {
|
||||
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
|
||||
if (!linkedNode) return []
|
||||
return linkedNode.imgs
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* A set of handlers which define widget interaction
|
||||
* Many arguments are shared between function calls
|
||||
* @param {IBaseWidget} _t - The "target" the call is originally made on.
|
||||
* This argument is never used, but must be defined for typechecking
|
||||
* @param {string} property - The name of the accessed value.
|
||||
* Checked for conditional logic, but never changed
|
||||
* @param {object} receiver - The object the result is set to
|
||||
* and the value used as 'this' if property is a get/set method
|
||||
* @param {unknown} value - only used on set calls. The thing being assigned
|
||||
*/
|
||||
let userLabel: string | undefined
|
||||
const handler = {
|
||||
get(_t: IBaseWidget, property: string, receiver: object) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
let redirectedReceiver = receiver
|
||||
if (property == '_overlay') return overlay
|
||||
else if (property == 'value') redirectedReceiver = backingWidget
|
||||
else if (property == 'label')
|
||||
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
redirectedReceiver = overlay
|
||||
}
|
||||
return Reflect.get(redirectedTarget, property, redirectedReceiver)
|
||||
},
|
||||
set(_t: IBaseWidget, property: string, value: unknown) {
|
||||
if (property == 'label') {
|
||||
userLabel = value as string | undefined
|
||||
return true
|
||||
}
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (property == 'computedHeight') {
|
||||
if (overlay.widgetName.startsWith('$$') && linkedNode) {
|
||||
updatePreviews(linkedNode)
|
||||
}
|
||||
if (linkedNode && linkedWidget?.computedDisabled) {
|
||||
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
|
||||
}
|
||||
//update linkage regularly, but no more than once per frame
|
||||
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
|
||||
backingWidget = linkedWidget ?? disconnectedWidget
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
}
|
||||
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
|
||||
},
|
||||
getPrototypeOf() {
|
||||
return Reflect.getPrototypeOf(backingWidget)
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(backingWidget)
|
||||
},
|
||||
has(_t: IBaseWidget, property: string) {
|
||||
let redirectedTarget: object = backingWidget
|
||||
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
|
||||
redirectedTarget = overlay
|
||||
}
|
||||
return Reflect.has(redirectedTarget, property)
|
||||
}
|
||||
}
|
||||
const w = new Proxy(disconnectedWidget, handler)
|
||||
return w
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
|
||||
import {
|
||||
isProxyWidget,
|
||||
isDisconnectedWidget
|
||||
} from '@/core/graph/subgraph/proxyWidget'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
|
||||
function getProxyWidgets(node: SubgraphNode) {
|
||||
return parseProxyWidgets(node.properties.proxyWidgets)
|
||||
}
|
||||
export function promoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
for (const parent of parents) {
|
||||
const existingProxyWidgets = getProxyWidgets(parent)
|
||||
// Prevent duplicate promotion
|
||||
if (existingProxyWidgets.some(matchesPropertyItem([node, widget]))) {
|
||||
continue
|
||||
}
|
||||
const proxyWidgets = [
|
||||
...existingProxyWidgets,
|
||||
widgetItemToProperty([node, widget])
|
||||
]
|
||||
parent.properties.proxyWidgets = proxyWidgets
|
||||
}
|
||||
widget.promoted = true
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
for (const parent of parents) {
|
||||
const proxyWidgets = getProxyWidgets(parent).filter(
|
||||
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
|
||||
)
|
||||
parent.properties.proxyWidgets = proxyWidgets
|
||||
}
|
||||
widget.promoted = false
|
||||
}
|
||||
|
||||
function getWidgetName(w: IBaseWidget): string {
|
||||
return isProxyWidget(w) ? w._overlay.widgetName : w.name
|
||||
}
|
||||
|
||||
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
|
||||
return ([n, w]: WidgetItem) =>
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
}
|
||||
export function matchesPropertyItem([n, w]: WidgetItem) {
|
||||
return ([nodeId, widgetName]: [string, string]) =>
|
||||
n.id == nodeId && getWidgetName(w) === widgetName
|
||||
}
|
||||
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
|
||||
return [`${n.id}`, getWidgetName(w)]
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
//NOTE: support for determining parents of a subgraph is limited
|
||||
//This function will require rework to properly support linked subgraphs
|
||||
//Either by including actual parents in the navigation stack,
|
||||
//or by adding a new event for parent listeners to collect from
|
||||
const { navigationStack } = useSubgraphNavigationStore()
|
||||
const subgraph = navigationStack.at(-1)
|
||||
if (!subgraph) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
})
|
||||
return []
|
||||
}
|
||||
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
|
||||
return parentGraph.nodes.filter(
|
||||
(node): node is SubgraphNode =>
|
||||
node.type === subgraph.id && node.isSubgraphNode()
|
||||
)
|
||||
}
|
||||
|
||||
export function addWidgetPromotionOptions(
|
||||
options: (IContextMenuValue<unknown> | null)[],
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode
|
||||
) {
|
||||
const parents = getParentNodes()
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
content: `Promote Widget: ${widget.label ?? widget.name}`,
|
||||
callback: () => {
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
})
|
||||
else {
|
||||
options.unshift({
|
||||
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
|
||||
callback: () => {
|
||||
demoteWidget(node, widget, parents)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
export function tryToggleWidgetPromotion() {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const [x, y] = canvas.graph_mouse
|
||||
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
|
||||
if (!node) return
|
||||
const widget = node.getWidgetOnPos(x, y, true)
|
||||
const parents = getParentNodes()
|
||||
if (!parents.length || !widget) return
|
||||
const promotableParents = parents.filter(
|
||||
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
else demoteWidget(node, widget, parents)
|
||||
}
|
||||
const recommendedNodes = [
|
||||
'CLIPTextEncode',
|
||||
'LoadImage',
|
||||
'SaveImage',
|
||||
'PreviewImage'
|
||||
]
|
||||
const recommendedWidgetNames = ['seed']
|
||||
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
||||
return (
|
||||
!widget.computedDisabled &&
|
||||
(recommendedNodes.includes(node.type) ||
|
||||
recommendedWidgetNames.includes(widget.name))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return n.widgets?.map((w: IBaseWidget) => [n, w]) ?? []
|
||||
}
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
node.updateComputedDisabled()
|
||||
function checkWidgets() {
|
||||
updatePreviews(node)
|
||||
const widget = node.widgets?.find((w) => w.name.startsWith('$$'))
|
||||
if (!widget) return
|
||||
const pw = getProxyWidgets(subgraphNode)
|
||||
if (pw.some(matchesPropertyItem([node, widget]))) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
requestAnimationFrame(() => updatePreviews(node, checkWidgets))
|
||||
}
|
||||
const filteredWidgets: WidgetItem[] = interiorNodes
|
||||
.flatMap(nodeWidgets)
|
||||
.filter(isRecommendedWidget)
|
||||
const proxyWidgets: ProxyWidgetsProperty =
|
||||
filteredWidgets.map(widgetItemToProperty)
|
||||
subgraphNode.properties.proxyWidgets = proxyWidgets
|
||||
subgraphNode.computeSize(subgraphNode.size)
|
||||
}
|
||||
|
||||
export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
|
||||
.filter(isProxyWidget)
|
||||
.filter((w) => !isDisconnectedWidget(w))
|
||||
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
|
||||
}
|
||||
29
src/core/graph/subgraph/resolvePromotedWidgetSource.ts
Normal file
29
src/core/graph/subgraph/resolvePromotedWidgetSource.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface ResolvedPromotedWidgetSource {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidgetSource | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(widget.sourceNodeId)
|
||||
if (!sourceNode) return undefined
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === widget.sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) return undefined
|
||||
|
||||
return {
|
||||
node: sourceNode,
|
||||
widget: sourceWidget
|
||||
}
|
||||
}
|
||||
287
src/core/graph/subgraph/subgraphNodePromotion.test.ts
Normal file
287
src/core/graph/subgraph/subgraphNodePromotion.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
return [subgraphNode, innerNodes]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'stringWidget' },
|
||||
{ interiorNodeId: '2', widgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
// Both views share the widget name; they're distinguished by sourceNodeId
|
||||
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
|
||||
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
|
||||
})
|
||||
test('Will reflect proxyWidgets order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetB')
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' }
|
||||
])
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
innerNodes[0].widgets[0].last_y = 11
|
||||
innerNodes[0].widgets[0].computedHeight = 12
|
||||
subgraphNode.widgets[0].y = 20
|
||||
subgraphNode.widgets[0].last_y = 21
|
||||
subgraphNode.widgets[0].computedHeight = 22
|
||||
expect(innerNodes[0].widgets[0].y).toBe(10)
|
||||
expect(innerNodes[0].widgets[0].last_y).toBe(11)
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Renders placeholder when interior widget is detached', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
// View resolves the interior widget's type
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
|
||||
// Remove interior widget — view falls back to disconnected state
|
||||
innerNodes[0].widgets.pop()
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
|
||||
// Re-add — view resolves again
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(innerNodes[0].id),
|
||||
'stringWidget'
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
store.promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(innerNodes[0].id),
|
||||
'stringWidget'
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list and view cache', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
const widgetToRemove = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(widgetToRemove)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
|
||||
})
|
||||
|
||||
test('removeWidgetByName removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
subgraphNode.removeWidgetByName('widgetA')
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
})
|
||||
|
||||
test('removeWidget cleans up input references', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
// Simulate an input referencing the widget
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
subgraphNode.removeWidget(view)
|
||||
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
['1', 'widgetA'],
|
||||
['1', 'widgetB']
|
||||
])
|
||||
})
|
||||
})
|
||||
56
src/core/graph/subgraph/unpromotedWidgetUtils.test.ts
Normal file
56
src/core/graph/subgraph/unpromotedWidgetUtils.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all enabled widgets are already promoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(interiorNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores computed-disabled widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
widget.computedDisabled = true
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
20
src/core/graph/subgraph/unpromotedWidgetUtils.ts
Normal file
20
src/core/graph/subgraph/unpromotedWidgetUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
const promotionStore = usePromotionStore()
|
||||
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
(interiorNode.widgets ?? []).some(
|
||||
(widget) =>
|
||||
!widget.computedDisabled &&
|
||||
!promotionStore.isPromoted(
|
||||
rootGraph.id,
|
||||
subgraphNodeId,
|
||||
String(interiorNode.id),
|
||||
widget.name
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { fromZodError } from 'zod-validation-error'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
|
||||
export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
@@ -5,6 +5,7 @@ import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IStringWidget
|
||||
@@ -143,10 +144,15 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(node.id, inputName)
|
||||
?.value as string) ?? ''
|
||||
(useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)?.value as string) ?? ''
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1075,7 +1075,11 @@
|
||||
"cannotDeleteGlobal": "Cannot delete installed blueprints",
|
||||
"enterDescription": "Enter a description",
|
||||
"searchAliases": "Search Aliases",
|
||||
"enterSearchAliases": "Enter search aliases (comma separated)"
|
||||
"enterSearchAliases": "Enter search aliases (comma separated)",
|
||||
"disconnected": "Disconnected",
|
||||
"linked": "(Linked)",
|
||||
"promoteWidget": "Promote Widget: {name}",
|
||||
"unpromoteWidget": "Un-Promote Widget: {name}"
|
||||
},
|
||||
"electronFileDownload": {
|
||||
"inProgress": "In Progress",
|
||||
|
||||
@@ -10,8 +10,10 @@ import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -135,6 +137,13 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
|
||||
isInSubgraph.value = true
|
||||
})
|
||||
|
||||
useEventListener(
|
||||
newCanvas.canvas,
|
||||
'subgraph-converted',
|
||||
(e: CustomEvent<{ subgraphNode: SubgraphNode }>) =>
|
||||
promoteRecommendedWidgets(e.detail.subgraphNode)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
ref="videoWrapperEl"
|
||||
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-if="videoError"
|
||||
role="alert"
|
||||
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
class="flex flex-auto flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
>
|
||||
<i
|
||||
class="mb-2 icon-[lucide--video-off] h-12 w-12 text-base-foreground"
|
||||
|
||||
19
src/renderer/extensions/vueNodes/components/AudioPreview.vue
Normal file
19
src/renderer/extensions/vueNodes/components/AudioPreview.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div v-if="audioUrls.length > 0" class="flex flex-col gap-2 px-2">
|
||||
<audio
|
||||
v-for="(url, index) in audioUrls"
|
||||
:key="index"
|
||||
:src="url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AudioPreviewProps {
|
||||
readonly audioUrls: readonly string[]
|
||||
}
|
||||
|
||||
defineProps<AudioPreviewProps>()
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
|
||||
class="min-h-0 flex-1 flex w-full overflow-hidden rounded-[5px] bg-muted-background relative"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-if="imageError"
|
||||
role="alert"
|
||||
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
class="flex flex-1 self-center size-full flex-col items-center justify-around bg-muted-background text-center text-base-foreground py-8"
|
||||
>
|
||||
<i
|
||||
class="mb-2 icon-[lucide--image-off] h-12 w-12 text-base-foreground"
|
||||
@@ -42,7 +42,7 @@
|
||||
v-if="!imageError"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="block size-full object-contain pointer-events-none contain-size"
|
||||
class="absolute inset-0 block size-full object-contain pointer-events-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
@@ -111,8 +111,18 @@
|
||||
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex flex-col">
|
||||
<NodeContent
|
||||
v-if="nodeMedia"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
/>
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<LivePreview
|
||||
@@ -256,6 +266,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
@@ -270,6 +281,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
@@ -525,7 +537,7 @@ watch(isCollapsed, (collapsed) => {
|
||||
|
||||
// Check if node has custom content (like image/video outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Show custom content if node has media outputs
|
||||
if (promotedPreviews.value.length > 0) return true
|
||||
return !!nodeMedia.value && nodeMedia.value.urls.length > 0
|
||||
})
|
||||
|
||||
@@ -639,15 +651,17 @@ const lgraphNode = computed(() => {
|
||||
return getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
})
|
||||
|
||||
// TODO: Surface subgraph info more cleanly in VueNodeData instead of
|
||||
// reaching through lgraphNode for promoted preview resolution.
|
||||
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
|
||||
|
||||
const showAdvancedInputsButton = computed(() => {
|
||||
const node = lgraphNode.value
|
||||
if (!node) return false
|
||||
|
||||
// For subgraph nodes: check for unpromoted widgets
|
||||
if (node instanceof SubgraphNode) {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
|
||||
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
|
||||
return hasUnpromotedWidgets(node)
|
||||
}
|
||||
|
||||
// For regular nodes: show button if there are advanced widgets and they're currently hidden
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ st('nodeErrors.content', 'Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content flex grow flex-col">
|
||||
<div v-else class="lg-node-content flex grow flex-col flex-auto">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<VideoPreview
|
||||
v-if="hasMedia && media?.type === 'video'"
|
||||
:image-urls="media.urls"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
<AudioPreview
|
||||
v-else-if="hasMedia && media?.type === 'audio'"
|
||||
:audio-urls="media.urls"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
<ImagePreview
|
||||
v-else-if="hasMedia && media?.type === 'image'"
|
||||
:image-urls="media.urls"
|
||||
:node-id="nodeId"
|
||||
class="mt-2"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
@@ -29,12 +34,13 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
import VideoPreview from '../VideoPreview.vue'
|
||||
import AudioPreview from './AudioPreview.vue'
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
nodeData?: VueNodeData
|
||||
media?: {
|
||||
type: 'image' | 'video'
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -115,7 +117,9 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
@@ -171,6 +175,7 @@ interface ProcessedWidget {
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeData.id ?? '']
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const { widgets } = nodeData
|
||||
@@ -187,7 +192,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
|
||||
// Get metadata from store (registered during BaseWidget.setNodeId)
|
||||
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
|
||||
const widgetState = widgetValueStore.getWidget(bareWidgetId, widget.name)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
|
||||
: undefined
|
||||
|
||||
// Get value from store (falls back to undefined if not registered)
|
||||
const value = widgetState?.value as WidgetValue
|
||||
@@ -198,9 +205,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
? { ...storeOptions, disabled: true }
|
||||
: storeOptions
|
||||
|
||||
// Derive border style from store metadata
|
||||
const isPromotedView = !!widget.nodeId
|
||||
const borderStyle =
|
||||
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
|
||||
graphId &&
|
||||
!isPromotedView &&
|
||||
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.options?.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
@@ -54,10 +55,12 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = app.canvas?.graph?.rootGraph.id
|
||||
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
void widgetStore.getWidget(nodeData.id, name)?.value
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
|
||||
@@ -3,7 +3,9 @@ import { whenever } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
@@ -17,12 +19,18 @@ const domEl = ref<HTMLElement>()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { canvas } = canvasStore
|
||||
|
||||
function findDOMWidget(): DOMWidget<HTMLElement, object | string> | undefined {
|
||||
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, props.widget.name)
|
||||
if (resolved && isDOMWidget(resolved.widget)) return resolved.widget
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mountWidgetElement() {
|
||||
if (!domEl.value) return
|
||||
const node = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
if (!node) return
|
||||
const widget = node.widgets?.find((w) => w.name === props.widget.name)
|
||||
if (!widget || !isDOMWidget(widget)) return
|
||||
const widget = findDOMWidget()
|
||||
if (!widget) return
|
||||
if (domEl.value.contains(widget.element)) return
|
||||
domEl.value.replaceChildren(widget.element)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { useResizeObserver, whenever } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
@@ -9,31 +9,46 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const canvasEl = ref()
|
||||
const containerHeight = ref(20)
|
||||
|
||||
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas: LGraphCanvas = canvasStore.canvas as LGraphCanvas
|
||||
let node: LGraphNode | undefined
|
||||
let widgetInstance: IBaseWidget | undefined
|
||||
let pointer: CanvasPointer | undefined
|
||||
const scaleFactor = 2
|
||||
|
||||
onMounted(() => {
|
||||
node =
|
||||
canvas?.graph?.getNodeById(
|
||||
canvasEl.value.parentElement.attributes['node-id'].value
|
||||
) ?? undefined
|
||||
if (!node) return
|
||||
widgetInstance = node.widgets?.find((w) => w.name === props.widget.name)
|
||||
if (!widgetInstance) return
|
||||
canvasEl.value.width *= scaleFactor
|
||||
function findLegacyWidget():
|
||||
| {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
| undefined {
|
||||
const hostNode = canvas?.graph?.getNodeById(props.nodeId) ?? undefined
|
||||
return resolveWidgetFromHostNode(hostNode, props.widget.name)
|
||||
}
|
||||
|
||||
function bindWidget() {
|
||||
if (widgetInstance) widgetInstance.triggerDraw = () => {}
|
||||
|
||||
const resolved = findLegacyWidget()
|
||||
if (!resolved) {
|
||||
widgetInstance = undefined
|
||||
node = undefined
|
||||
return
|
||||
}
|
||||
node = resolved.node
|
||||
widgetInstance = resolved.widget
|
||||
if (!widgetInstance.triggerDraw)
|
||||
widgetInstance.callback = useChainCallback(
|
||||
widgetInstance.callback,
|
||||
@@ -42,6 +57,13 @@ onMounted(() => {
|
||||
}
|
||||
)
|
||||
widgetInstance.triggerDraw = draw
|
||||
draw()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
canvasEl.value.width *= scaleFactor
|
||||
bindWidget()
|
||||
if (!widgetInstance) return
|
||||
useResizeObserver(canvasEl.value.parentElement, draw)
|
||||
watch(() => useColorPaletteStore().activePaletteId, draw)
|
||||
pointer = new CanvasPointer(canvasEl.value)
|
||||
@@ -50,6 +72,9 @@ onBeforeUnmount(() => {
|
||||
if (widgetInstance) widgetInstance.triggerDraw = () => {}
|
||||
})
|
||||
|
||||
whenever(() => !canvasStore.linearMode, bindWidget)
|
||||
watch(() => canvasStore.currentGraph, bindWidget)
|
||||
|
||||
function draw() {
|
||||
if (!widgetInstance || !node) return
|
||||
const width = canvasEl.value.parentElement.clientWidth
|
||||
|
||||
@@ -49,6 +49,8 @@ const renderPreview = (
|
||||
shiftY: number,
|
||||
computedHeight: number | undefined
|
||||
) => {
|
||||
if (!node.size) return
|
||||
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -15,17 +14,14 @@ import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
const findFileComboWidget = (
|
||||
node: LGraphNode,
|
||||
inputName: string
|
||||
): IComboWidget | undefined =>
|
||||
node.widgets?.find((w): w is IComboWidget => w.name === inputName)
|
||||
|
||||
export const useImageUploadWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
@@ -51,24 +47,11 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
if (!fileComboWidget) {
|
||||
throw new Error(`Widget "${imageInputName}" not found on node`)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
const formatPath = (value: string) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
@@ -77,10 +60,12 @@ export const useImageUploadWidget = () => {
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
const annotated = output.map(formatPath)
|
||||
annotated.forEach((path) => {
|
||||
addToComboValues(fileComboWidget, path)
|
||||
})
|
||||
|
||||
// Create a NEW array to ensure Vue reactivity detects the change
|
||||
const newValue = allow_batch ? [...output] : output[0]
|
||||
const newValue = allow_batch ? annotated : annotated[0]
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = newValue
|
||||
@@ -103,7 +88,7 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function () {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
@@ -113,7 +98,7 @@ export const useImageUploadWidget = () => {
|
||||
// The value isn't set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
nodeOutputStore.setNodeOutputs(node, String(fileComboWidget.value), {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
|
||||
@@ -7,6 +7,7 @@ import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -48,15 +49,15 @@ function addMarkdownWidget(
|
||||
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
return (
|
||||
(widgetStore.getWidget(node.id, name)?.value as string) ??
|
||||
textarea.value
|
||||
)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
return typeof storedValue === 'string' ? storedValue : textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
const widgetState = widgetStore.getWidget(node.id, name)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -36,9 +38,15 @@ export function useTextPreviewWidget(
|
||||
},
|
||||
options: {
|
||||
getValue: () =>
|
||||
useWidgetValueStore().getWidget(node.id, inputSpec.name)?.value ?? '',
|
||||
useWidgetValueStore().getWidget(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)?.value ?? '',
|
||||
setValue: (value: string | object) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -18,19 +19,22 @@ function addMultilineWidget(
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.dataset.testid = 'dom-widget-textarea'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
|
||||
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
const widgetState = widgetStore.getWidget(node.id, name)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
|
||||
return (widgetState?.value as string) ?? inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
const widgetState = widgetStore.getWidget(node.id, name)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(widgets: IBaseWidget[]) {
|
||||
super('TestNode')
|
||||
this.widgets = widgets
|
||||
}
|
||||
}
|
||||
|
||||
class TestSubgraphNode extends TestNode {
|
||||
constructor(
|
||||
widgets: IBaseWidget[],
|
||||
innerNodesById: Record<string, LGraphNode> = {}
|
||||
) {
|
||||
super(widgets)
|
||||
this.subgraph = {
|
||||
getNodeById: (nodeId: string) => innerNodesById[nodeId]
|
||||
} as SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
override isSubgraphNode(): this is SubgraphNode {
|
||||
return true
|
||||
}
|
||||
|
||||
readonly subgraph: SubgraphNode['subgraph']
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget &
|
||||
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text',
|
||||
y: 0,
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
|
||||
function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): TestPromotedWidget {
|
||||
return {
|
||||
...createWidget(name),
|
||||
sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
function createHostNode(
|
||||
widgets: IBaseWidget[],
|
||||
options: {
|
||||
isSubgraphNode?: boolean
|
||||
innerNodesById?: Record<string, LGraphNode>
|
||||
} = {}
|
||||
): LGraphNode {
|
||||
const { isSubgraphNode = false, innerNodesById = {} } = options
|
||||
return isSubgraphNode
|
||||
? new TestSubgraphNode(widgets, innerNodesById)
|
||||
: new TestNode(widgets)
|
||||
}
|
||||
|
||||
describe('resolveWidgetFromHostNode', () => {
|
||||
it('returns host node widget for non-promoted widgets', () => {
|
||||
const widget = createWidget('text_widget')
|
||||
const hostNode = createHostNode([widget])
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
})
|
||||
|
||||
it('resolves promoted widget to the interior node widget', () => {
|
||||
const innerWidget = createWidget('inner_text')
|
||||
const innerNode = createHostNode([innerWidget])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior node is missing', () => {
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when promoted interior widget is missing', () => {
|
||||
const innerNode = createHostNode([])
|
||||
const promotedWidget = createPromotedWidget(
|
||||
'promoted_text',
|
||||
'42',
|
||||
'inner_text'
|
||||
)
|
||||
const hostNode = createHostNode([promotedWidget], {
|
||||
isSubgraphNode: true,
|
||||
innerNodesById: { '42': innerNode }
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
|
||||
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
|
||||
const hostNode = createHostNode([widget], {
|
||||
isSubgraphNode: false
|
||||
})
|
||||
|
||||
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
|
||||
|
||||
expect(resolved).toEqual({ node: hostNode, widget })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type ResolvedWidget = {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export function resolveWidgetFromHostNode(
|
||||
hostNode: LGraphNode | undefined,
|
||||
widgetName: string
|
||||
): ResolvedWidget | undefined {
|
||||
if (!hostNode) return undefined
|
||||
|
||||
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
const sourceWidget = resolvePromotedWidgetSource(hostNode, widget)
|
||||
if (sourceWidget) return sourceWidget
|
||||
|
||||
if (isPromotedWidgetView(widget) && hostNode.isSubgraphNode())
|
||||
return undefined
|
||||
|
||||
return {
|
||||
node: hostNode,
|
||||
widget
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { shallowRef } from 'vue'
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
|
||||
|
||||
import { st, t } from '@/i18n'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
@@ -892,8 +892,6 @@ export class ComfyApp {
|
||||
}
|
||||
)
|
||||
|
||||
registerProxyWidgets(this.canvas)
|
||||
|
||||
this.rootGraph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
@@ -1314,18 +1312,18 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reset_invalid_values) {
|
||||
if (widget.type == 'combo') {
|
||||
const values = widget.options.values as
|
||||
| (string | number | boolean)[]
|
||||
| undefined
|
||||
if (
|
||||
values &&
|
||||
values.length > 0 &&
|
||||
!values.includes(widget.value as string | number | boolean)
|
||||
) {
|
||||
widget.value = values[0]
|
||||
}
|
||||
if (widget.type == 'combo') {
|
||||
const values = widget.options.values as
|
||||
| (string | number | boolean)[]
|
||||
| undefined
|
||||
if (
|
||||
values &&
|
||||
values.length > 0 &&
|
||||
(widget.value == null ||
|
||||
(reset_invalid_values &&
|
||||
!values.includes(widget.value as string | number | boolean)))
|
||||
) {
|
||||
widget.value = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
|
||||
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
@@ -10,10 +12,41 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/promotionStore', () => ({
|
||||
usePromotionStore: () => ({
|
||||
isPromotedByAny: isPromotedByAnyMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
generateUUID: () => 'test-uuid'
|
||||
}))
|
||||
|
||||
type MockCanvasContext = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
| 'beginPath'
|
||||
| 'rect'
|
||||
| 'fill'
|
||||
| 'save'
|
||||
| 'restore'
|
||||
| 'strokeRect'
|
||||
| 'fillStyle'
|
||||
| 'strokeStyle'
|
||||
>
|
||||
|
||||
function createMockContext(): MockCanvasContext {
|
||||
return {
|
||||
beginPath: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000'
|
||||
}
|
||||
}
|
||||
|
||||
describe('DOMWidget Y Position Preservation', () => {
|
||||
test('BaseDOMWidgetImpl createCopyForNode preserves Y position', () => {
|
||||
const mockNode = new LGraphNode('test-node')
|
||||
@@ -80,3 +113,83 @@ describe('DOMWidget Y Position Preservation', () => {
|
||||
expect(clonedWidget.y).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOMWidget draw promotion behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('draws promoted outline for visible promoted widgets', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(true)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
node.graph = { rootGraph } as never
|
||||
const onDraw = vi.fn()
|
||||
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'seed',
|
||||
type: 'text',
|
||||
element: document.createElement('div'),
|
||||
options: { onDraw }
|
||||
})
|
||||
const ctx = createMockContext()
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).toHaveBeenCalledWith(
|
||||
'root-graph-id',
|
||||
'-1',
|
||||
'seed'
|
||||
)
|
||||
expect(ctx.strokeRect).toHaveBeenCalledOnce()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('does not draw promoted outline when widget is not promoted', () => {
|
||||
isPromotedByAnyMock.mockReturnValue(false)
|
||||
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
node.graph = { rootGraph } as never
|
||||
const onDraw = vi.fn()
|
||||
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'seed',
|
||||
type: 'text',
|
||||
element: document.createElement('div'),
|
||||
options: { onDraw }
|
||||
})
|
||||
const ctx = createMockContext()
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(ctx.strokeRect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('skips promotion lookup when widget is hidden', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
const rootGraph = { id: 'root-graph-id' }
|
||||
node.graph = { rootGraph } as never
|
||||
const onDraw = vi.fn()
|
||||
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'seed',
|
||||
type: 'text',
|
||||
element: document.createElement('div'),
|
||||
options: { onDraw }
|
||||
})
|
||||
widget.hidden = true
|
||||
const ctx = createMockContext()
|
||||
|
||||
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
|
||||
|
||||
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
|
||||
expect(ctx.strokeRect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<
|
||||
@@ -124,6 +125,7 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
declare readonly name: string
|
||||
declare readonly options: DOMWidgetOptions<V>
|
||||
declare callback?: (value: V) => void
|
||||
readonly promotionStore = usePromotionStore()
|
||||
|
||||
readonly id: string
|
||||
|
||||
@@ -164,7 +166,9 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
widget_height: number,
|
||||
lowQuality?: boolean
|
||||
): void {
|
||||
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
|
||||
const isVisible = this.isVisible()
|
||||
|
||||
if (this.options.hideOnZoom && lowQuality && isVisible) {
|
||||
// Draw a placeholder rectangle
|
||||
const originalFillStyle = ctx.fillStyle
|
||||
ctx.beginPath()
|
||||
@@ -177,7 +181,25 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = originalFillStyle
|
||||
} else if (this.promoted && this.isVisible()) {
|
||||
} else {
|
||||
if (!isVisible) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
}
|
||||
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const isPromoted =
|
||||
graphId &&
|
||||
this.promotionStore.isPromotedByAny(
|
||||
graphId,
|
||||
String(this.node.id),
|
||||
this.name
|
||||
)
|
||||
if (!isPromoted) {
|
||||
this.options.onDraw?.(this)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
const adjustedMargin = this.margin - 1
|
||||
ctx.beginPath()
|
||||
|
||||
@@ -6,7 +6,10 @@ import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import {
|
||||
addWidgetPromotionOptions,
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { applyDynamicInputs } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { st, t } from '@/i18n'
|
||||
import {
|
||||
@@ -37,6 +40,8 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import type { SubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
ComfyNodeDef as ComfyNodeDefV2,
|
||||
@@ -51,6 +56,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -127,6 +133,30 @@ export const useLitegraphService = () => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subgraphPseudoWidgetCache = new WeakMap<
|
||||
SubgraphNode,
|
||||
SubgraphPseudoWidgetCache<LGraphNode, IBaseWidget>
|
||||
>()
|
||||
|
||||
function invalidateSubgraphPseudoWidgetCache(node: SubgraphNode) {
|
||||
subgraphPseudoWidgetCache.delete(node)
|
||||
}
|
||||
|
||||
function getPseudoWidgetPreviewTargets(node: SubgraphNode): LGraphNode[] {
|
||||
const promotionStore = usePromotionStore()
|
||||
const promotions = promotionStore.getPromotionsRef(
|
||||
node.rootGraph.id,
|
||||
node.id
|
||||
)
|
||||
const resolved = resolveSubgraphPseudoWidgetCache({
|
||||
cache: subgraphPseudoWidgetCache.get(node) ?? null,
|
||||
promotions,
|
||||
getNodeById: (nodeId) => node.subgraph.getNodeById(nodeId) ?? undefined,
|
||||
isPreviewPseudoWidget
|
||||
})
|
||||
subgraphPseudoWidgetCache.set(node, resolved.cache)
|
||||
return resolved.nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
@@ -317,6 +347,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
// Set up event listener for promoted widget registration
|
||||
subgraph.events.addEventListener('widget-promoted', (event) => {
|
||||
invalidateSubgraphPseudoWidgetCache(this)
|
||||
const { widget } = event.detail
|
||||
// Only handle DOM widgets
|
||||
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
||||
@@ -336,6 +367,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
// Set up event listener for promoted widget removal
|
||||
subgraph.events.addEventListener('widget-demoted', (event) => {
|
||||
invalidateSubgraphPseudoWidgetCache(this)
|
||||
const { widget } = event.detail
|
||||
// Only handle DOM widgets
|
||||
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
||||
@@ -794,6 +826,15 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
node.prototype.onDrawBackground = function () {
|
||||
updatePreviews(this)
|
||||
|
||||
if (this instanceof SubgraphNode) {
|
||||
const parentGraph = this.graph
|
||||
for (const interiorNode of getPseudoWidgetPreviewTargets(this)) {
|
||||
updatePreviews(interiorNode, () => {
|
||||
parentGraph?.setDirtyCanvas(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
src/services/subgraphPseudoWidgetCache.test.ts
Normal file
167
src/services/subgraphPseudoWidgetCache.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import type {
|
||||
SubgraphPseudoWidget,
|
||||
SubgraphPseudoWidgetCache,
|
||||
SubgraphPseudoWidgetNode,
|
||||
SubgraphPromotionEntry
|
||||
} from '@/services/subgraphPseudoWidgetCache'
|
||||
|
||||
interface TestWidget extends SubgraphPseudoWidget {
|
||||
isPseudo?: boolean
|
||||
}
|
||||
|
||||
interface TestNode extends SubgraphPseudoWidgetNode<TestWidget> {
|
||||
widgets?: TestWidget[]
|
||||
}
|
||||
|
||||
function widget(name: string, isPseudo = false): TestWidget {
|
||||
return { name, isPseudo }
|
||||
}
|
||||
|
||||
function node(id: string, widgets: TestWidget[] = []): TestNode {
|
||||
return { id, widgets }
|
||||
}
|
||||
|
||||
describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
it('builds update targets from pseudo widget promotions', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const getNodeById = vi.fn((id: string) =>
|
||||
id === 'n1' ? interiorNode : undefined
|
||||
)
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([interiorNode])
|
||||
expect(result.cache.entries).toHaveLength(1)
|
||||
expect(getNodeById).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
|
||||
it('keeps $$ fallback behavior when the backing widget is missing', () => {
|
||||
const interiorNode = node('n1', [widget('other')])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([interiorNode])
|
||||
})
|
||||
|
||||
it('reuses cache when promotions and node identities are unchanged', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const getNodeById = vi.fn((id: string) =>
|
||||
id === 'n1' ? interiorNode : undefined
|
||||
)
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: base.cache,
|
||||
promotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).toBe(base.cache)
|
||||
expect(result.nodes).toBe(base.nodes)
|
||||
expect(getNodeById).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebuilds cache when promotions reference changes', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotionsA: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions: promotionsA,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const promotionsB: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: base.cache,
|
||||
promotions: promotionsB,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).not.toBe(base.cache)
|
||||
})
|
||||
|
||||
it('falls back to rebuild when a cached node reference goes stale', () => {
|
||||
const oldNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: () => oldNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const newNode = node('n1', [widget('preview', true)])
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: initial.cache,
|
||||
promotions,
|
||||
getNodeById: () => newNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).not.toBe(initial.cache)
|
||||
expect(result.nodes).toEqual([newNode])
|
||||
})
|
||||
|
||||
it('drops cached entries when node no longer resolves', () => {
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'missing', widgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
const cache: SubgraphPseudoWidgetCache<TestNode, TestWidget> = {
|
||||
promotions,
|
||||
entries: [
|
||||
{
|
||||
interiorNodeId: 'missing',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
node: node('missing')
|
||||
}
|
||||
],
|
||||
nodes: [node('missing')]
|
||||
}
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache,
|
||||
promotions,
|
||||
getNodeById: () => undefined,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.cache.entries).toEqual([])
|
||||
})
|
||||
})
|
||||
120
src/services/subgraphPseudoWidgetCache.ts
Normal file
120
src/services/subgraphPseudoWidgetCache.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export interface SubgraphPromotionEntry {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidget {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidgetNode<
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
id: string | number
|
||||
widgets?: TWidget[]
|
||||
}
|
||||
|
||||
interface SubgraphPseudoWidgetCacheEntry<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
node: TNode
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidgetCache<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
entries: SubgraphPseudoWidgetCacheEntry<TNode, TWidget>[]
|
||||
nodes: TNode[]
|
||||
}
|
||||
|
||||
interface ResolveSubgraphPseudoWidgetCacheArgs<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget> | null
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
getNodeById: (nodeId: string) => TNode | undefined
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
}
|
||||
|
||||
interface ResolveSubgraphPseudoWidgetCacheResult<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget>
|
||||
nodes: TNode[]
|
||||
}
|
||||
|
||||
function isPseudoPromotion<TWidget extends SubgraphPseudoWidget>(
|
||||
widgetName: string,
|
||||
widgets: TWidget[] | undefined,
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
): boolean {
|
||||
const widget = widgets?.find((candidate) => candidate.name === widgetName)
|
||||
if (widget) return isPreviewPseudoWidget(widget)
|
||||
return widgetName.startsWith('$$')
|
||||
}
|
||||
|
||||
function isCacheStillValid<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
>(
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget>,
|
||||
getNodeById: (nodeId: string) => TNode | undefined,
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
): boolean {
|
||||
return cache.entries.every((entry) => {
|
||||
const currentNode = getNodeById(entry.interiorNodeId)
|
||||
if (!currentNode || currentNode !== entry.node) return false
|
||||
return isPseudoPromotion(
|
||||
entry.widgetName,
|
||||
currentNode.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveSubgraphPseudoWidgetCache<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
>(
|
||||
args: ResolveSubgraphPseudoWidgetCacheArgs<TNode, TWidget>
|
||||
): ResolveSubgraphPseudoWidgetCacheResult<TNode, TWidget> {
|
||||
const { cache, promotions, getNodeById, isPreviewPseudoWidget } = args
|
||||
|
||||
if (
|
||||
cache &&
|
||||
cache.promotions === promotions &&
|
||||
isCacheStillValid(cache, getNodeById, isPreviewPseudoWidget)
|
||||
)
|
||||
return { cache, nodes: cache.nodes }
|
||||
|
||||
const entries = promotions.flatMap((promotion) => {
|
||||
const node = getNodeById(promotion.interiorNodeId)
|
||||
if (!node) return []
|
||||
if (
|
||||
!isPseudoPromotion(
|
||||
promotion.widgetName,
|
||||
node.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ ...promotion, node }]
|
||||
})
|
||||
|
||||
const nextCache: SubgraphPseudoWidgetCache<TNode, TWidget> = {
|
||||
promotions,
|
||||
entries,
|
||||
nodes: entries.map((entry) => entry.node)
|
||||
}
|
||||
return {
|
||||
cache: nextCache,
|
||||
nodes: nextCache.nodes
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,15 @@ import { computed, markRaw, ref } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
|
||||
interface PositionOverride {
|
||||
node: Raw<LGraphNode>
|
||||
widget: Raw<IBaseWidget>
|
||||
}
|
||||
|
||||
export interface DomWidgetState extends PositionConfig {
|
||||
// Raw widget instance
|
||||
widget: Raw<BaseDOMWidget<object | string>>
|
||||
@@ -16,6 +23,8 @@ export interface DomWidgetState extends PositionConfig {
|
||||
zIndex: number
|
||||
/** If the widget belongs to the current graph/subgraph. */
|
||||
active: boolean
|
||||
/** Override positioning to render on a different node (e.g. SubgraphNode). */
|
||||
positionOverride?: PositionOverride
|
||||
}
|
||||
|
||||
export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
@@ -28,10 +37,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
[...widgetStates.value.values()].filter((state) => !state.active)
|
||||
)
|
||||
|
||||
// Register a widget with the store
|
||||
const registerWidget = <V extends object | string>(
|
||||
widget: BaseDOMWidget<V>
|
||||
) => {
|
||||
function registerWidget<V extends object | string>(widget: BaseDOMWidget<V>) {
|
||||
widgetStates.value.set(widget.id, {
|
||||
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
|
||||
visible: true,
|
||||
@@ -43,29 +49,49 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Unregister a widget from the store
|
||||
const unregisterWidget = (widgetId: string) => {
|
||||
function unregisterWidget(widgetId: string) {
|
||||
widgetStates.value.delete(widgetId)
|
||||
}
|
||||
|
||||
const activateWidget = (widgetId: string) => {
|
||||
function activateWidget(widgetId: string) {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = true
|
||||
}
|
||||
|
||||
const deactivateWidget = (widgetId: string) => {
|
||||
function deactivateWidget(widgetId: string) {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = false
|
||||
}
|
||||
|
||||
const setWidget = (widget: BaseDOMWidget) => {
|
||||
function setWidget(widget: BaseDOMWidget) {
|
||||
const state = widgetStates.value.get(widget.id)
|
||||
if (!state) return
|
||||
state.active = true
|
||||
state.widget = widget
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
function setPositionOverride(widgetId: string, override: PositionOverride) {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (!state) return
|
||||
const current = state.positionOverride
|
||||
if (
|
||||
current &&
|
||||
current.node === override.node &&
|
||||
current.widget === override.widget
|
||||
)
|
||||
return
|
||||
state.positionOverride = {
|
||||
node: markRaw(override.node),
|
||||
widget: markRaw(override.widget)
|
||||
}
|
||||
}
|
||||
|
||||
function clearPositionOverride(widgetId: string) {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.positionOverride = undefined
|
||||
}
|
||||
|
||||
function clear() {
|
||||
widgetStates.value.clear()
|
||||
}
|
||||
|
||||
@@ -78,6 +104,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
activateWidget,
|
||||
deactivateWidget,
|
||||
setWidget,
|
||||
setPositionOverride,
|
||||
clearPositionOverride,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -429,14 +430,12 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
return nodeDef.inputs[widgetName]
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
//TODO: resolve spec for linked
|
||||
if (!widget || !isProxyWidget(widget)) return undefined
|
||||
if (!widget || !isPromotedWidgetView(widget)) return undefined
|
||||
|
||||
const { nodeId, widgetName: subWidgetName } = widget._overlay
|
||||
const subNode = node.subgraph.getNodeById(nodeId)
|
||||
if (!subNode) return undefined
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) return undefined
|
||||
|
||||
return getInputSpecForWidget(subNode, subWidgetName)
|
||||
return getInputSpecForWidget(sourceWidget.node, sourceWidget.widget.name)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
310
src/stores/promotionStore.test.ts
Normal file
310
src/stores/promotionStore.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { usePromotionStore } from './promotionStore'
|
||||
|
||||
describe(usePromotionStore, () => {
|
||||
let store: ReturnType<typeof usePromotionStore>
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
const nodeId = 1 as NodeId
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = usePromotionStore()
|
||||
})
|
||||
|
||||
describe('getPromotions', () => {
|
||||
it('returns empty array for unknown node', () => {
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns entries after setPromotions', () => {
|
||||
const entries = [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
})
|
||||
|
||||
it('returns a defensive copy', () => {
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
|
||||
const result = store.getPromotions(graphA, nodeId)
|
||||
result.push({ interiorNodeId: '11', widgetName: 'steps' })
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPromoted', () => {
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a promoted entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a different widget on the same node', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'steps')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPromotedByAny', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when promoted by one parent', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when promoted by multiple parents', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false after demoting from all parents', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
store.demote(graphA, nodeB, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when still promoted by one parent after partial demote', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
expect(store.isPromotedByAny(graphA, '10', 'steps')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions', () => {
|
||||
it('replaces existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromoted(graphA, nodeId, '11', 'steps')).toBe(true)
|
||||
})
|
||||
|
||||
it('clears entries when set to empty array', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.setPromotions(graphA, nodeId, [])
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves order', () => {
|
||||
const entries = [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promote', () => {
|
||||
it('adds a new entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('appends to existing entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('demote', () => {
|
||||
it('removes an existing entry', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.demote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op for non-existent entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.demote(graphA, nodeId, '99', 'nonexistent')
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves other entries', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.demote(graphA, nodeId, '10', 'seed')
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('movePromotion', () => {
|
||||
it('moves an entry from one index to another', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.promote(graphA, nodeId, '12', 'cfg')
|
||||
store.movePromotion(graphA, nodeId, 0, 2)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' },
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for out-of-bounds indices', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.movePromotion(graphA, nodeId, 0, 5)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when fromIndex equals toIndex', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphA, nodeId, '11', 'steps')
|
||||
store.movePromotion(graphA, nodeId, 1, 1)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ref-counted isPromotedByAny', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('tracks across setPromotions calls', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeB, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
|
||||
// Remove from A — still promoted by B
|
||||
store.setPromotions(graphA, nodeA, [])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
|
||||
// Remove from B — now gone
|
||||
store.setPromotions(graphA, nodeB, [])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles replacement via setPromotions correctly', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '10', widgetName: 'seed' },
|
||||
{ interiorNodeId: '11', widgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
|
||||
// Replace with different entries
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ interiorNodeId: '11', widgetName: 'steps' },
|
||||
{ interiorNodeId: '12', widgetName: 'cfg' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '12', 'cfg')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays consistent through movePromotion', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeA, '11', 'steps')
|
||||
store.movePromotion(graphA, nodeA, 0, 1)
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
|
||||
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-node isolation', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('keeps promotions separate per subgraph node', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '20', 'cfg')
|
||||
|
||||
expect(store.getPromotions(graphA, nodeA)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphA, nodeB)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'cfg' }
|
||||
])
|
||||
})
|
||||
|
||||
it('demoting from one node does not affect another', () => {
|
||||
store.promote(graphA, nodeA, '10', 'seed')
|
||||
store.promote(graphA, nodeB, '10', 'seed')
|
||||
store.demote(graphA, nodeA, '10', 'seed')
|
||||
|
||||
expect(store.isPromoted(graphA, nodeA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromoted(graphA, nodeB, '10', 'seed')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates promotions by graph id', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphB, nodeId, '20', 'steps')
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ interiorNodeId: '10', widgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
store.promote(graphA, nodeId, '10', 'seed')
|
||||
store.promote(graphB, nodeId, '20', 'steps')
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'steps' }
|
||||
])
|
||||
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
|
||||
expect(store.isPromotedByAny(graphB, '20', 'steps')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
186
src/stores/promotionStore.ts
Normal file
186
src/stores/promotionStore.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface PromotionEntry {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export const usePromotionStore = defineStore('promotion', () => {
|
||||
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
|
||||
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
|
||||
|
||||
function _getPromotionsForGraph(
|
||||
graphId: UUID
|
||||
): Map<NodeId, PromotionEntry[]> {
|
||||
const promotions = graphPromotions.value.get(graphId)
|
||||
if (promotions) return promotions
|
||||
|
||||
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
|
||||
graphPromotions.value.set(graphId, nextPromotions)
|
||||
return nextPromotions
|
||||
}
|
||||
|
||||
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
|
||||
const refCounts = graphRefCounts.value.get(graphId)
|
||||
if (refCounts) return refCounts
|
||||
|
||||
const nextRefCounts = new Map<string, number>()
|
||||
graphRefCounts.value.set(graphId, nextRefCounts)
|
||||
return nextRefCounts
|
||||
}
|
||||
|
||||
function _makeKey(interiorNodeId: string, widgetName: string): string {
|
||||
return `${interiorNodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
function _incrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = _makeKey(e.interiorNodeId, e.widgetName)
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function _decrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = _makeKey(e.interiorNodeId, e.widgetName)
|
||||
const count = (refCounts.get(key) ?? 1) - 1
|
||||
if (count <= 0) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPromotionsRef(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
|
||||
}
|
||||
|
||||
function getPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotionEntry[] {
|
||||
return [...getPromotionsRef(graphId, subgraphNodeId)]
|
||||
}
|
||||
|
||||
function isPromoted(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return getPromotionsRef(graphId, subgraphNodeId).some(
|
||||
(e) => e.interiorNodeId === interiorNodeId && e.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
function isPromotedByAny(
|
||||
graphId: UUID,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
return (refCounts.get(_makeKey(interiorNodeId, widgetName)) ?? 0) > 0
|
||||
}
|
||||
|
||||
function setPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
entries: PromotionEntry[]
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const oldEntries = promotions.get(subgraphNodeId) ?? []
|
||||
_decrementKeys(graphId, oldEntries)
|
||||
_incrementKeys(graphId, entries)
|
||||
|
||||
if (entries.length === 0) {
|
||||
promotions.delete(subgraphNodeId)
|
||||
} else {
|
||||
promotions.set(subgraphNodeId, [...entries])
|
||||
}
|
||||
}
|
||||
|
||||
function promote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): void {
|
||||
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(graphId, subgraphNodeId, [
|
||||
...entries,
|
||||
{ interiorNodeId, widgetName }
|
||||
])
|
||||
}
|
||||
|
||||
function demote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): void {
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(
|
||||
graphId,
|
||||
subgraphNodeId,
|
||||
entries.filter(
|
||||
(e) =>
|
||||
!(e.interiorNodeId === interiorNodeId && e.widgetName === widgetName)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function movePromotion(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const currentEntries = promotions.get(subgraphNodeId)
|
||||
if (!currentEntries?.length) return
|
||||
|
||||
const entries = [...currentEntries]
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
fromIndex >= entries.length ||
|
||||
toIndex < 0 ||
|
||||
toIndex >= entries.length ||
|
||||
fromIndex === toIndex
|
||||
)
|
||||
return
|
||||
|
||||
const [entry] = entries.splice(fromIndex, 1)
|
||||
entries.splice(toIndex, 0, entry)
|
||||
|
||||
// Reordering does not change membership, so ref-counts remain valid.
|
||||
promotions.set(subgraphNodeId, entries)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphPromotions.value.delete(graphId)
|
||||
graphRefCounts.value.delete(graphId)
|
||||
}
|
||||
|
||||
return {
|
||||
getPromotionsRef,
|
||||
getPromotions,
|
||||
isPromoted,
|
||||
isPromotedByAny,
|
||||
setPromotions,
|
||||
promote,
|
||||
demote,
|
||||
movePromotion,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
@@ -18,6 +19,8 @@ function widget<T>(
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -25,31 +28,37 @@ describe('useWidgetValueStore', () => {
|
||||
describe('widgetState.value access', () => {
|
||||
it('getWidget returns undefined for unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget('missing', 'widget')).toBeUndefined()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('widgetState.value can be read and written directly', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
expect(state.value).toBe(100)
|
||||
|
||||
state.value = 200
|
||||
expect(store.getWidget('node-1', 'seed')?.value).toBe(200)
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('stores different value types', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(widget('node-1', 'text', 'string', 'hello'))
|
||||
store.registerWidget(widget('node-1', 'number', 'number', 42))
|
||||
store.registerWidget(widget('node-1', 'boolean', 'toggle', true))
|
||||
store.registerWidget(widget('node-1', 'array', 'combo', [1, 2, 3]))
|
||||
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
|
||||
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
|
||||
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'array', 'combo', [1, 2, 3])
|
||||
)
|
||||
|
||||
expect(store.getWidget('node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget('node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget('node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget('node-1', 'array')?.value).toEqual([1, 2, 3])
|
||||
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +66,7 @@ describe('useWidgetValueStore', () => {
|
||||
it('registers a widget with minimal properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 12345)
|
||||
)
|
||||
|
||||
@@ -65,7 +75,6 @@ describe('useWidgetValueStore', () => {
|
||||
expect(state.type).toBe('number')
|
||||
expect(state.value).toBe(12345)
|
||||
expect(state.disabled).toBeUndefined()
|
||||
expect(state.promoted).toBeUndefined()
|
||||
expect(state.serialize).toBeUndefined()
|
||||
expect(state.options).toEqual({})
|
||||
})
|
||||
@@ -73,10 +82,10 @@ describe('useWidgetValueStore', () => {
|
||||
it('registers a widget with all properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'string', 'test', {
|
||||
label: 'Prompt Text',
|
||||
disabled: true,
|
||||
promoted: true,
|
||||
serialize: false,
|
||||
options: { multiline: true }
|
||||
})
|
||||
@@ -84,7 +93,6 @@ describe('useWidgetValueStore', () => {
|
||||
|
||||
expect(state.label).toBe('Prompt Text')
|
||||
expect(state.disabled).toBe(true)
|
||||
expect(state.promoted).toBe(true)
|
||||
expect(state.serialize).toBe(false)
|
||||
expect(state.options).toEqual({ multiline: true })
|
||||
})
|
||||
@@ -93,9 +101,9 @@ describe('useWidgetValueStore', () => {
|
||||
describe('widget getters', () => {
|
||||
it('getWidget returns widget state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(widget('node-1', 'seed', 'number', 100))
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
|
||||
|
||||
const state = store.getWidget('node-1', 'seed')
|
||||
const state = store.getWidget(graphA, 'node-1', 'seed')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.name).toBe('seed')
|
||||
expect(state?.value).toBe(100)
|
||||
@@ -103,16 +111,16 @@ describe('useWidgetValueStore', () => {
|
||||
|
||||
it('getWidget returns undefined for missing widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget('missing', 'widget')).toBeUndefined()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNodeWidgets returns all widgets for a node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(widget('node-2', 'cfg', 'number', 7))
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
|
||||
|
||||
const widgets = store.getNodeWidgets('node-1')
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1')
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
|
||||
})
|
||||
@@ -122,34 +130,50 @@ describe('useWidgetValueStore', () => {
|
||||
it('disabled can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
|
||||
state.disabled = true
|
||||
expect(store.getWidget('node-1', 'seed')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('promoted can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
|
||||
state.promoted = true
|
||||
expect(store.getWidget('node-1', 'seed')?.promoted).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('label can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
|
||||
state.label = 'Random Seed'
|
||||
expect(store.getWidget('node-1', 'seed')?.label).toBe('Random Seed')
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
|
||||
'Random Seed'
|
||||
)
|
||||
|
||||
state.label = undefined
|
||||
expect(store.getWidget('node-1', 'seed')?.label).toBeUndefined()
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
@@ -28,50 +29,61 @@ export interface WidgetState<
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> extends Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
| 'name'
|
||||
| 'type'
|
||||
| 'value'
|
||||
| 'options'
|
||||
| 'label'
|
||||
| 'serialize'
|
||||
| 'disabled'
|
||||
| 'promoted'
|
||||
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
|
||||
> {
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const widgetStates = ref(new Map<WidgetKey, WidgetState>())
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
if (widgetStates) return widgetStates
|
||||
|
||||
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
|
||||
graphWidgetStates.value.set(graphId, nextWidgetStates)
|
||||
return nextWidgetStates
|
||||
}
|
||||
|
||||
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
function registerWidget<TValue = unknown>(
|
||||
graphId: UUID,
|
||||
state: WidgetState<TValue>
|
||||
): WidgetState<TValue> {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.value.set(key, state)
|
||||
return widgetStates.value.get(key) as WidgetState<TValue>
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
function getNodeWidgets(nodeId: NodeId): WidgetState[] {
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
return [...widgetStates.value]
|
||||
return [...widgetStates]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, state]) => state)
|
||||
}
|
||||
|
||||
function getWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
): WidgetState | undefined {
|
||||
return widgetStates.value.get(makeKey(nodeId, widgetName))
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
}
|
||||
|
||||
return {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getNodeWidgets
|
||||
getNodeWidgets,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -31,12 +32,12 @@ export function getWidgetDefaultValue(
|
||||
|
||||
/**
|
||||
* Renames a widget and its corresponding input.
|
||||
* Handles both regular widgets and proxy widgets in subgraphs.
|
||||
* Handles both regular widgets and promoted widget views in subgraphs.
|
||||
*
|
||||
* @param widget The widget to rename
|
||||
* @param node The node containing the widget
|
||||
* @param newLabel The new label for the widget (empty string or undefined to clear)
|
||||
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
|
||||
* @param parents Optional array of parent SubgraphNodes (for promoted widgets)
|
||||
* @returns true if the rename was successful, false otherwise
|
||||
*/
|
||||
export function renameWidget(
|
||||
@@ -45,46 +46,28 @@ export function renameWidget(
|
||||
newLabel: string,
|
||||
parents?: SubgraphNode[]
|
||||
): boolean {
|
||||
// For proxy widgets in subgraphs, we need to rename the original interior widget
|
||||
if (isProxyWidget(widget) && parents?.length) {
|
||||
const subgraph = parents[0].subgraph
|
||||
if (!subgraph) {
|
||||
console.error('Could not find subgraph for proxy widget')
|
||||
return false
|
||||
}
|
||||
const interiorNode = subgraph.getNodeById(widget._overlay.nodeId)
|
||||
|
||||
if (!interiorNode) {
|
||||
console.error('Could not find interior node for proxy widget')
|
||||
if (isPromotedWidgetView(widget) && parents?.length) {
|
||||
const sourceWidget = resolvePromotedWidgetSource(node, widget)
|
||||
if (!sourceWidget) {
|
||||
console.error('Could not resolve source widget for promoted widget')
|
||||
return false
|
||||
}
|
||||
|
||||
const originalWidget = interiorNode.widgets?.find(
|
||||
(w) => w.name === widget._overlay.widgetName
|
||||
)
|
||||
const originalWidget = sourceWidget.widget
|
||||
const interiorNode = sourceWidget.node
|
||||
|
||||
if (!originalWidget) {
|
||||
console.error('Could not find original widget for proxy widget')
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the original widget
|
||||
originalWidget.label = newLabel || undefined
|
||||
|
||||
// Also rename the corresponding input on the interior node
|
||||
const interiorInput = interiorNode.inputs?.find(
|
||||
(inp) => inp.widget?.name === widget._overlay.widgetName
|
||||
(inp) => inp.widget?.name === originalWidget.name
|
||||
)
|
||||
if (interiorInput) {
|
||||
interiorInput.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Always rename the widget on the current node (either regular widget or proxy widget)
|
||||
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
|
||||
|
||||
// Intentionally mutate the widget object here as it's a reference
|
||||
// to the actual widget in the graph
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
|
||||
Reference in New Issue
Block a user