mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 07:30:08 +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() {
|
||||
|
||||
Reference in New Issue
Block a user