Add UI code for configuring subgraphNode widgets (#5826)

The third PR for managing display of widgets on subgraph nodes. This is
the one that actually makes the functionality usable and user visible.

Adds
- A right-side modal for configuring which widgets are promoted,
accessed by right click or selection toolbar
- This menu allows for re-arranging widget order by dragging and
dropping.
- Indicators inside the subgraph for which widgets have been promoted.
- Context menu options for promoting or demoting widget inside of a
subgraph.
<img width="767" height="694" alt="image"
src="https://github.com/user-attachments/assets/4f78645d-7b26-48ba-8c49-78f4807e89e8"
/>
<img width="784" height="435" alt="image"
src="https://github.com/user-attachments/assets/7005c730-a732-481e-befb-57019a8a31a7"
/>


Known issues
- Some preview widgets are not added to a node until a draw operation
occurs. The code does not yet have a way of determining which nodes
should have draw operations forced to facilitate initial widget
creation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5826-Add-UI-code-for-configuring-subgraphNode-widgets-27c6d73d36508146accbf395e5bcd36a)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2025-10-02 17:19:47 -07:00
committed by GitHub
parent 48335475dc
commit 720de8cc8a
32 changed files with 795 additions and 154 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -22,7 +22,8 @@
<ColorPickerButton v-if="showColorPicker" /> <ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" /> <FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" /> <ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<PublishSubgraphButton v-if="showPublishSubgraph" /> <ConfigureSubgraph v-if="showSubgraphButtons" />
<PublishSubgraphButton v-if="showSubgraphButtons" />
<MaskEditorButton v-if="showMaskEditor" /> <MaskEditorButton v-if="showMaskEditor" />
<VerticalDivider <VerticalDivider
v-if="showAnyPrimaryActions && showAnyControlActions" v-if="showAnyPrimaryActions && showAnyControlActions"
@@ -50,6 +51,7 @@ import { computed, ref } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue' import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue' import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue' import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue' import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue' import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
@@ -112,7 +114,7 @@ const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value) const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value) const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value) const showFrameNodes = computed(() => hasMultipleSelection.value)
const showPublishSubgraph = computed(() => isSingleSubgraph.value) const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed( const showBypass = computed(
() => () =>
@@ -130,7 +132,7 @@ const showAnyPrimaryActions = computed(
showColorPicker.value || showColorPicker.value ||
showConvertToSubgraph.value || showConvertToSubgraph.value ||
showFrameNodes.value || showFrameNodes.value ||
showPublishSubgraph.value showSubgraphButtons.value
) )
const showAnyControlActions = computed(() => showBypass.value) const showAnyControlActions = computed(() => showBypass.value)

View File

@@ -0,0 +1,17 @@
<template>
<Button
v-tooltip.top="{
value: $t('Edit Subgraph Widgets'),
showDelay: 1000
}"
severity="secondary"
text
icon="icon-[lucide--settings-2]"
@click="showSubgraphNodeDialog"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
</script>

View File

@@ -68,7 +68,7 @@ const updateDomClipping = () => {
return return
} }
const isSelected = selectedNode === widget.node const isSelected = selectedNode === widgetState.widget.node
const renderArea = selectedNode?.renderArea const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale const scale = lgCanvas.ds.scale

View File

@@ -5,6 +5,7 @@ import {
DEFAULT_DARK_COLOR_PALETTE, DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes' } from '@/constants/coreColorPalettes'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import { t } from '@/i18n' import { t } from '@/i18n'
import { import {
LGraphEventMode, LGraphEventMode,
@@ -909,6 +910,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { node } = res const { node } = res
canvas.select(node) canvas.select(node)
promoteRecommendedWidgets(node)
canvasStore.updateSelectedItems() canvasStore.updateSelectedItems()
} }
}, },

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { refDebounced, watchDebounced } from '@vueuse/core'
import {
computed,
customRef,
onBeforeUnmount,
onMounted,
ref,
triggerRef
} from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SubgraphNodeWidget from '@/core/graph/subgraph/SubgraphNodeWidget.vue'
import {
type WidgetItem,
demoteWidget,
isRecommendedWidget,
matchesPropertyItem,
matchesWidgetItem,
promoteWidget,
widgetItemToProperty
} from '@/core/graph/subgraph/proxyWidgetUtils'
import {
type ProxyWidgetsProperty,
parseProxyWidgets
} from '@/core/schemas/proxyWidget'
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 { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { useDialogStore } from '@/stores/dialogStore'
const canvasStore = useCanvasStore()
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const searchQuery = ref<string>('')
const debouncedQuery = refDebounced(searchQuery, 200)
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 activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
useDialogStore().closeDialog()
return undefined
})
const activeWidgets = computed<WidgetItem[]>({
get() {
const node = activeNode.value
if (!node) return []
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const w = wNode.widgets.find((w) => w.name === name)
if (!w) return []
return [[wNode, w]]
})
},
set(value: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
//map back to id/name
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
proxyWidgets.value = widgets
}
})
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const { updatePreviews } = useLitegraphService()
const interiorNodes = node.subgraph.nodes
for (const node of interiorNodes) {
node.updateComputedDisabled()
updatePreviews(node)
}
return interiorNodes
.flatMap(nodeWidgets)
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
})
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))
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
const query = debouncedQuery.value.toLowerCase()
if (!query) return candidateWidgets.value
return candidateWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
const recommendedWidgets = computed(() => {
const node = activeNode.value
if (!node) return [] //Not reachable
return filteredCandidates.value.filter(isRecommendedWidget)
})
const filteredActive = computed<WidgetItem[]>(() => {
const query = debouncedQuery.value.toLowerCase()
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
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])
}
function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
demoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
promoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
}
function showAll() {
const node = activeNode.value
if (!node) return //Not reachable
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
filteredCandidates.value.map(widgetItemToProperty)
widgets.push(...toAdd)
proxyWidgets.value = widgets
}
function hideAll() {
const node = activeNode.value
if (!node) return //Not reachable
//Not great from a nesting perspective, but path is cold
//and it cleans up potential error states
proxyWidgets.value = proxyWidgets.value.filter(
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
)
}
function showRecommended() {
const node = activeNode.value
if (!node) return //Not reachable
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
recommendedWidgets.value.map(widgetItemToProperty)
//TODO: Add sort step here
//Input should always be before output by default
widgets.push(...toAdd)
proxyWidgets.value = widgets
}
function setDraggableState() {
draggableList.value?.dispose()
if (debouncedQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
//Original implementation plays really poorly with vue,
//It has been modified to not add/remove elements
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watchDebounced(
filteredActive,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<SearchBox
v-model:model-value="searchQuery"
class="p-2"
:placeholder="$t('g.search') + '...'"
/>
<div
v-if="filteredActive.length"
class="pt-1 pb-4 border-b-1 border-sand-100 dark-theme:border-charcoal-600"
>
<div class="flex py-0 px-4 justify-between">
<div class="text-slate-100 text-[9px] font-semibold uppercase">
{{ $t('subgraphStore.shown') }}
</div>
<a
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
@click.stop="hideAll"
>
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<div ref="draggableItems">
<div
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="w-full draggable-item"
style=""
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
</div>
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
<div class="flex py-0 px-4 justify-between">
<div class="text-slate-100 text-[9px] font-semibold uppercase">
{{ $t('subgraphStore.hidden') }}
</div>
<a
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
@click.stop="showAll"
>
{{ $t('subgraphStore.showAll') }}</a
>
</div>
<div
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="w-full"
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
</div>
<div
v-if="recommendedWidgets.length"
class="justify-center flex py-4 border-t-1 border-sand-100 dark-theme:border-charcoal-600"
>
<Button
size="small"
class="rounded border-none px-3 py-0.5"
@click.stop="showRecommended"
>
{{ $t('subgraphStore.showRecommended') }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
nodeTitle: string
widgetName: string
isShown?: boolean
isDraggable?: boolean
}>()
defineEmits<{
(e: 'toggleVisibility'): void
}>()
function classes() {
return cn(
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
'bg-pure-white dark-theme:bg-charcoal-800',
props.isDraggable
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
: ''
)
}
</script>
<template>
<div :class="classes()">
<div
:class="
cn(
'size-4 pointer-events-none',
isDraggable ? 'icon-[lucide--grip-vertical]' : ''
)
"
/>
<div class="flex-1 pointer-events-none">
<div class="text-slate-100 text-[10px]">{{ nodeTitle }}</div>
<div class="text-xs">{{ widgetName }}</div>
</div>
<Button
size="small"
text
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
severity="secondary"
@click.stop="$emit('toggleVisibility')"
/>
</div>
</template>

View File

@@ -1,13 +1,18 @@
import { useNodeImage } from '@/composables/node/useNodeImage' import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget' import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' 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 type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget' import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DOMWidgetImpl } from '@/scripts/domWidget' import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useLitegraphService } from '@/services/litegraphService'
import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil' import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
/** /**
@@ -43,14 +48,33 @@ function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
} }
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
)
}
}
})
SubgraphNode.prototype.onConfigure = onConfigure
}
const originalOnConfigure = SubgraphNode.prototype.onConfigure const originalOnConfigure = SubgraphNode.prototype.onConfigure
SubgraphNode.prototype.onConfigure = function (serialisedNode) { const onConfigure = function (
this: LGraphNode,
serialisedNode: ISerialisedNode
) {
if (!this.isSubgraphNode()) if (!this.isSubgraphNode())
throw new Error("Can't add proxyWidgets to non-subgraphNode") throw new Error("Can't add proxyWidgets to non-subgraphNode")
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize //Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= '[]' this.properties.proxyWidgets ??= []
let proxyWidgets = this.properties.proxyWidgets let proxyWidgets = this.properties.proxyWidgets
originalOnConfigure?.call(this, serialisedNode) originalOnConfigure?.call(this, serialisedNode)
@@ -62,13 +86,16 @@ SubgraphNode.prototype.onConfigure = function (serialisedNode) {
set: (property: string) => { set: (property: string) => {
const parsed = parseProxyWidgets(property) const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore() const { deactivateWidget, setWidget } = useDomWidgetStore()
for (const w of this.widgets.filter((w) => isProxyWidget(w))) { const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id) if (isActiveGraph) {
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
} }
this.widgets = this.widgets.filter((w) => !isProxyWidget(w)) this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
for (const [nodeId, widgetName] of parsed) { for (const [nodeId, widgetName] of parsed) {
const w = addProxyWidget(this, `${nodeId}`, widgetName) const w = addProxyWidget(this, `${nodeId}`, widgetName)
if (w instanceof DOMWidgetImpl) setWidget(w) if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
} }
proxyWidgets = property proxyWidgets = property
canvasStore.canvas?.setDirty(true, true) canvasStore.canvas?.setDirty(true, true)
@@ -86,19 +113,23 @@ function addProxyWidget(
) { ) {
const name = `${nodeId}: ${widgetName}` const name = `${nodeId}: ${widgetName}`
const overlay = { const overlay = {
//items specific for proxy management
nodeId, nodeId,
widgetName,
graph: subgraphNode.subgraph, graph: subgraphNode.subgraph,
name, widgetName,
label: name, //Items which normally exist on widgets
isProxyWidget: true,
y: 0,
last_y: undefined,
width: undefined,
computedHeight: undefined,
afterQueued: undefined, afterQueued: undefined,
computedHeight: undefined,
isProxyWidget: true,
label: name,
last_y: undefined,
name,
node: subgraphNode,
onRemove: undefined, onRemove: undefined,
node: subgraphNode promoted: undefined,
serialize: false,
width: undefined,
y: 0
} }
return addProxyFromOverlay(subgraphNode, overlay) return addProxyFromOverlay(subgraphNode, overlay)
} }
@@ -110,23 +141,20 @@ function resolveLinkedWidget(
if (!n) return [undefined, undefined] if (!n) return [undefined, undefined]
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)] return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
} }
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay) let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget let backingWidget = linkedWidget ?? disconnectedWidget
if (overlay.widgetName == '$$canvas-image-preview') if (overlay.widgetName.startsWith('$$')) {
overlay.node = new Proxy(subgraphNode, { overlay.node = new Proxy(subgraphNode, {
get(_t, p) { get(_t, p) {
if (p !== 'imgs') return Reflect.get(subgraphNode, p) if (p !== 'imgs') return Reflect.get(subgraphNode, p)
if (!linkedNode) return [] if (!linkedNode) return []
const images =
useNodeOutputStore().getNodeOutputs(linkedNode)?.images ?? []
if (images !== linkedNode.images) {
linkedNode.images = images
useNodeImage(linkedNode).showPreview()
}
return linkedNode.imgs return linkedNode.imgs
} }
}) })
}
/** /**
* A set of handlers which define widget interaction * A set of handlers which define widget interaction
* Many arguments are shared between function calls * Many arguments are shared between function calls
@@ -155,6 +183,12 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
let redirectedReceiver = receiver let redirectedReceiver = receiver
if (property == 'value') redirectedReceiver = backingWidget if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'computedHeight') { else 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 //update linkage regularly, but no more than once per frame
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay) ;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget backingWidget = linkedWidget ?? disconnectedWidget

View File

@@ -0,0 +1,132 @@
import {
type ProxyWidgetsProperty,
parseProxyWidgets
} from '@/core/schemas/proxyWidget'
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 { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
export type WidgetItem = [LGraphNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
export function promoteWidget(
node: LGraphNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
for (const parent of parents) {
const proxyWidgets = [
...getProxyWidgets(parent),
widgetItemToProperty([node, widget])
]
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = true
}
export function demoteWidget(
node: LGraphNode,
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
}
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
}
export function matchesPropertyItem([n, w]: WidgetItem) {
return ([nodeId, widgetName]: [string, string]) =>
n.id == nodeId && w.name === widgetName
}
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, w.name]
}
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) throw new Error("Can't promote widget when not in subgraph")
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)
}
})
else {
options.unshift({
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
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()
//NOTE: Since this operation is async, previews still don't exist after the single frame
//Add an onLoad callback to updatePreviews?
updatePreviews(node)
}
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
const proxyWidgets: ProxyWidgetsProperty =
filteredWidgets.map(widgetItemToProperty)
subgraphNode.properties.proxyWidgets = proxyWidgets
}

View File

@@ -0,0 +1,26 @@
import SubgraphNode from '@/core/graph/subgraph/SubgraphNode.vue'
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
const key = 'global-subgraph-node-config'
export function showSubgraphNodeDialog() {
const dialogStore = useDialogStore()
const dialogComponentProps: DialogComponentProps = {
modal: false,
position: 'topright',
pt: {
root: {
class: 'bg-pure-white dark-theme:bg-charcoal-800 mt-22'
},
header: {
class: 'h-8 text-xs ml-3'
}
}
}
dialogStore.showDialog({
title: 'Parameters',
key,
component: SubgraphNode,
dialogComponentProps
})
}

View File

@@ -4,18 +4,12 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()])) const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema> export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets( export function parseProxyWidgets(
property: NodeProperty | undefined property: NodeProperty | undefined
): ProxyWidgetsProperty { ): ProxyWidgetsProperty {
if (typeof property !== 'string') { const result = proxyWidgetsPropertySchema.safeParse(property)
throw new Error(
'Invalid assignment for properties.proxyWidgets:\nValue must be a string'
)
}
const parsed = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(parsed)
if (result.success) return result.data if (result.success) return result.data
const error = fromZodError(result.error) const error = fromZodError(result.error)

View File

@@ -1862,13 +1862,13 @@ export class LGraphCanvas
this.#dirty() this.#dirty()
} }
openSubgraph(subgraph: Subgraph): void { openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
const { graph } = this const { graph } = this
if (!graph) throw new NullGraphError() if (!graph) throw new NullGraphError()
const options = { const options = {
bubbles: true, bubbles: true,
detail: { subgraph, closingGraph: graph }, detail: { subgraph, closingGraph: graph, fromNode },
cancelable: true cancelable: true
} }
const mayContinue = this.canvas.dispatchEvent( const mayContinue = this.canvas.dispatchEvent(
@@ -2794,7 +2794,7 @@ export class LGraphCanvas
if (pos[1] < 0 && !inCollapse) { if (pos[1] < 0 && !inCollapse) {
node.onNodeTitleDblClick?.(e, pos, this) node.onNodeTitleDblClick?.(e, pos, this)
} else if (node instanceof SubgraphNode) { } else if (node instanceof SubgraphNode) {
this.openSubgraph(node.subgraph) this.openSubgraph(node.subgraph, node)
} }
node.onDblClick?.(e, pos, this) node.onDblClick?.(e, pos, this)
@@ -8007,7 +8007,7 @@ export class LGraphCanvas
if (Object.keys(this.selected_nodes).length > 1) { if (Object.keys(this.selected_nodes).length > 1) {
options.push( options.push(
{ {
content: 'Convert to Subgraph 🆕', content: 'Convert to Subgraph',
callback: () => { callback: () => {
if (!this.selectedItems.size) if (!this.selectedItems.size)
throw new Error('Convert to Subgraph: Nothing selected.') throw new Error('Convert to Subgraph: Nothing selected.')
@@ -8042,7 +8042,7 @@ export class LGraphCanvas
} else { } else {
options = [ options = [
{ {
content: 'Convert to Subgraph 🆕', content: 'Convert to Subgraph',
callback: () => { callback: () => {
// find groupnodes, degroup and select children // find groupnodes, degroup and select children
if (this.selectedItems.size) { if (this.selectedItems.size) {

View File

@@ -3749,6 +3749,13 @@ export class LGraphNode
return !isHidden return !isHidden
} }
updateComputedDisabled() {
if (!this.widgets) return
for (const widget of this.widgets)
widget.computedDisabled =
widget.disabled || this.getSlotFromWidget(widget)?.link != null
}
drawWidgets( drawWidgets(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
{ lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions { lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions
@@ -3762,6 +3769,7 @@ export class LGraphNode
ctx.save() ctx.save()
ctx.globalAlpha = editorAlpha ctx.globalAlpha = editorAlpha
this.updateComputedDisabled()
for (const widget of widgets) { for (const widget of widgets) {
if (!this.isWidgetVisible(widget)) continue if (!this.isWidgetVisible(widget)) continue
@@ -3771,9 +3779,6 @@ export class LGraphNode
: LiteGraph.WIDGET_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR
widget.last_y = y widget.last_y = y
// Disable widget if it is disabled or if the value is passed from socket connection.
widget.computedDisabled =
widget.disabled || this.getSlotFromWidget(widget)?.link != null
ctx.strokeStyle = outlineColour ctx.strokeStyle = outlineColour
ctx.fillStyle = '#222' ctx.fillStyle = '#222'

View File

@@ -70,6 +70,7 @@ export class LiteGraphGlobal {
WIDGET_BGCOLOR = '#222' WIDGET_BGCOLOR = '#222'
WIDGET_OUTLINE_COLOR = '#666' WIDGET_OUTLINE_COLOR = '#666'
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)' WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
WIDGET_TEXT_COLOR = '#DDD' WIDGET_TEXT_COLOR = '#DDD'
WIDGET_SECONDARY_TEXT_COLOR = '#999' WIDGET_SECONDARY_TEXT_COLOR = '#999'

View File

@@ -4,6 +4,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces' import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
export interface LGraphCanvasEventMap { export interface LGraphCanvasEventMap {
@@ -14,6 +15,11 @@ export interface LGraphCanvasEventMap {
/** The old active graph, or `null` if there was no active graph. */ /** The old active graph, or `null` if there was no active graph. */
oldGraph: LGraph | Subgraph | null | undefined oldGraph: LGraph | Subgraph | null | undefined
} }
'subgraph-opened': {
subgraph: Subgraph
closingGraph: LGraph
fromNode: SubgraphNode
}
'litegraph:canvas': 'litegraph:canvas':
| { subType: 'before-change' | 'after-change' } | { subType: 'before-change' | 'after-change' }

View File

@@ -168,7 +168,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
canvas: LGraphCanvas canvas: LGraphCanvas
): void { ): void {
if (button.name === 'enter_subgraph') { if (button.name === 'enter_subgraph') {
canvas.openSubgraph(this.subgraph) canvas.openSubgraph(this.subgraph, this)
} else { } else {
super.onTitleButtonClick(button, canvas) super.onTitleButtonClick(button, canvas)
} }

View File

@@ -308,6 +308,13 @@ export interface IBaseWidget<
hidden?: boolean hidden?: boolean
advanced?: 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 tooltip?: string

View File

@@ -74,6 +74,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
computedDisabled?: boolean computedDisabled?: boolean
hidden?: boolean hidden?: boolean
advanced?: boolean advanced?: boolean
promoted?: boolean
tooltip?: string tooltip?: string
element?: HTMLElement element?: HTMLElement
callback?( callback?(
@@ -146,6 +147,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
} }
get outline_color() { get outline_color() {
if (this.promoted) return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR

View File

@@ -96,6 +96,7 @@
"searchModels": "Search Models", "searchModels": "Search Models",
"searchKeybindings": "Search Keybindings", "searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions", "searchExtensions": "Search Extensions",
"search": "Search",
"noResultsFound": "No Results Found", "noResultsFound": "No Results Found",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found", "noTasksFound": "No Tasks Found",
@@ -352,6 +353,7 @@
"Color": "Color", "Color": "Color",
"Add Subgraph to Library": "Add Subgraph to Library", "Add Subgraph to Library": "Add Subgraph to Library",
"Unpack Subgraph": "Unpack Subgraph", "Unpack Subgraph": "Unpack Subgraph",
"Edit Subgraph Widgets": "Edit Subgraph Widgets",
"Convert to Subgraph": "Convert to Subgraph", "Convert to Subgraph": "Convert to Subgraph",
"Align Selected To": "Align Selected To", "Align Selected To": "Align Selected To",
"Distribute Nodes": "Distribute Nodes", "Distribute Nodes": "Distribute Nodes",
@@ -1075,7 +1077,12 @@
"publish": "Publish Subgraph", "publish": "Publish Subgraph",
"publishSuccess": "Saved to Nodes Library", "publishSuccess": "Saved to Nodes Library",
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"", "publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
"loadFailure": "Failed to load subgraph blueprints" "loadFailure": "Failed to load subgraph blueprints",
"shown": "Shown on node",
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
}, },
"electronFileDownload": { "electronFileDownload": {
"inProgress": "In Progress", "inProgress": "In Progress",

View File

@@ -3,7 +3,11 @@ import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
@@ -182,6 +186,7 @@ interface WorkflowStore {
updateActiveGraph: () => void updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: ( nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null ) => NodeLocatorId | null
@@ -577,6 +582,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
return createNodeLocatorId(targetSubgraph.id, nodeId) return createNodeLocatorId(targetSubgraph.id, nodeId)
} }
/**
* Convert a node to a NodeLocatorId
* Does not assume the node resides in the active graph
* @param The actual node instance
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
if (isSubgraph(node.graph))
return createNodeLocatorId(node.graph.id, node.id)
return String(node.id)
}
/** /**
* Convert an execution ID to a NodeLocatorId * Convert an execution ID to a NodeLocatorId
@@ -719,6 +735,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
updateActiveGraph, updateActiveGraph,
executionIdToCurrentId, executionIdToCurrentId,
nodeIdToNodeLocatorId, nodeIdToNodeLocatorId,
nodeToNodeLocatorId,
nodeExecutionIdToNodeLocatorId, nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId, nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId nodeLocatorIdToNodeExecutionId

View File

@@ -332,7 +332,7 @@ const handleEnterSubgraph = () => {
return return
} }
canvas.openSubgraph(litegraphNode.subgraph) canvas.openSubgraph(litegraphNode.subgraph, litegraphNode)
} }
const nodeOutputs = useNodeOutputStore() const nodeOutputs = useNodeOutputStore()

View File

@@ -19,7 +19,8 @@ import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = ( const renderPreview = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
node: LGraphNode, node: LGraphNode,
shiftY: number shiftY: number,
computedHeight: number | undefined
) => { ) => {
const canvas = useCanvasStore().getCanvas() const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse const mouse = canvas.graph_mouse
@@ -46,7 +47,7 @@ const renderPreview = (
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw') const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0 const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0] const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
if (imageIndex == null) { if (imageIndex == null) {
// No image selected; draw thumbnails of all // No image selected; draw thumbnails of all
@@ -260,7 +261,7 @@ class ImagePreviewWidget extends BaseWidget {
} }
override drawWidget(ctx: CanvasRenderingContext2D): void { override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y) renderPreview(ctx, this.node, this.y, this.computedHeight)
} }
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean { override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {

View File

@@ -5,6 +5,7 @@ import { reactive, unref } from 'vue'
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { st, t } from '@/i18n' import { st, t } from '@/i18n'
import { import {
LGraph, LGraph,
@@ -883,6 +884,7 @@ export class ComfyApp {
} }
} }
) )
registerProxyWidgets(this.canvas)
this.graph.start() this.graph.start()

View File

@@ -173,6 +173,18 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
) )
ctx.fill() ctx.fill()
ctx.fillStyle = originalFillStyle ctx.fillStyle = originalFillStyle
} else if (this.promoted && this.isVisible()) {
ctx.save()
const adjustedMargin = this.margin - 1
ctx.beginPath()
ctx.strokeStyle = LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
ctx.strokeRect(
adjustedMargin,
y + adjustedMargin,
widget_width - adjustedMargin * 2,
(this.computedHeight ?? widget_height) - 2 * adjustedMargin
)
ctx.restore()
} }
this.options.onDraw?.(this) this.options.onDraw?.(this)
} }

View File

@@ -23,11 +23,8 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
*/ */
import { $el } from '../ui' const styleElement = document.createElement('style')
styleElement.textContent = `
$el('style', {
parent: document.head,
textContent: `
.draggable-item { .draggable-item {
position: relative; position: relative;
will-change: transform; will-change: transform;
@@ -40,7 +37,7 @@ $el('style', {
z-index: 10; z-index: 10;
} }
` `
}) document.head.append(styleElement)
export class DraggableList extends EventTarget { export class DraggableList extends EventTarget {
listContainer listContainer

View File

@@ -1,4 +1,3 @@
import '@/core/graph/subgraph/proxyWidget'
import { t } from '@/i18n' import { t } from '@/i18n'
import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph' import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { import type {

View File

@@ -4,6 +4,8 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage' import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview' import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage' import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { st, t } from '@/i18n' import { st, t } from '@/i18n'
import { import {
type IContextMenuValue, type IContextMenuValue,
@@ -741,7 +743,7 @@ export const useLitegraphService = () => {
] ]
} }
node.prototype.getExtraMenuOptions = function (_, options) { node.prototype.getExtraMenuOptions = function (canvas, options) {
if (this.imgs) { if (this.imgs) {
// If this node has images then we add an open in new tab item // If this node has images then we add an open in new tab item
let img let img
@@ -788,7 +790,7 @@ export const useLitegraphService = () => {
content: 'Bypass', content: 'Bypass',
callback: () => { callback: () => {
toggleSelectedNodesMode(LGraphEventMode.BYPASS) toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true) canvas.setDirty(true, true)
} }
}) })
@@ -824,18 +826,88 @@ export const useLitegraphService = () => {
} }
} }
if (this instanceof SubgraphNode) { if (this instanceof SubgraphNode) {
options.unshift({ options.unshift(
content: 'Unpack Subgraph', {
callback: () => { content: 'Edit Subgraph Widgets',
useNodeOutputStore().revokeSubgraphPreviews(this) callback: () => {
this.graph.unpackSubgraph(this) showSubgraphNodeDialog()
}
},
{
content: 'Unpack Subgraph',
callback: () => {
useNodeOutputStore().revokeSubgraphPreviews(this)
this.graph.unpackSubgraph(this)
}
} }
}) )
}
if (this.graph && !this.graph.isRootGraph) {
const [x, y] = canvas.canvas_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
addWidgetPromotionOptions(options, overWidget, this)
}
} }
return [] return []
} }
} }
function updatePreviews(node: LGraphNode) {
try {
unsafeUpdatePreviews.call(node)
} catch (error) {
console.error('Error drawing node background', error)
}
}
function unsafeUpdatePreviews(this: LGraphNode) {
if (this.flags.collapsed) return
const nodeOutputStore = useNodeOutputStore()
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
const { showCanvasImagePreview, removeCanvasImagePreview } =
useNodeCanvasImagePreview()
const output = nodeOutputStore.getNodeOutputs(this)
const preview = nodeOutputStore.getNodePreviews(this)
const isNewOutput = output && this.images !== output.images
const isNewPreview = preview && this.preview !== preview
if (isNewPreview) this.preview = preview
if (isNewOutput) this.images = output.images
if (isNewOutput || isNewPreview) {
this.animatedImages = output?.animated?.find(Boolean)
const isAnimatedWebp =
this.animatedImages &&
output?.images?.some((img) => img.filename?.includes('webp'))
const isAnimatedPng =
this.animatedImages &&
output?.images?.some((img) => img.filename?.includes('png'))
const isVideo =
(this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
isVideoNode(this)
if (isVideo) {
useNodeVideo(this).showPreview()
} else {
useNodeImage(this).showPreview()
}
}
// Nothing to do
if (!this.imgs?.length) return
if (this.animatedImages) {
removeCanvasImagePreview(this)
showAnimatedPreview(this)
} else {
removeAnimatedPreview(this)
showCanvasImagePreview(this)
}
}
/** /**
* Adds Custom drawing logic for nodes * Adds Custom drawing logic for nodes
@@ -851,62 +923,8 @@ export const useLitegraphService = () => {
'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.' 'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.'
) )
} }
function unsafeDrawBackground(this: LGraphNode) {
if (this.flags.collapsed) return
const nodeOutputStore = useNodeOutputStore()
const { showAnimatedPreview, removeAnimatedPreview } =
useNodeAnimatedImage()
const { showCanvasImagePreview, removeCanvasImagePreview } =
useNodeCanvasImagePreview()
const output = nodeOutputStore.getNodeOutputs(this)
const preview = nodeOutputStore.getNodePreviews(this)
const isNewOutput = output && this.images !== output.images
const isNewPreview = preview && this.preview !== preview
if (isNewPreview) this.preview = preview
if (isNewOutput) this.images = output.images
if (isNewOutput || isNewPreview) {
this.animatedImages = output?.animated?.find(Boolean)
const isAnimatedWebp =
this.animatedImages &&
output?.images?.some((img) => img.filename?.includes('webp'))
const isAnimatedPng =
this.animatedImages &&
output?.images?.some((img) => img.filename?.includes('png'))
const isVideo =
(this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
isVideoNode(this)
if (isVideo) {
useNodeVideo(this).showPreview()
} else {
useNodeImage(this).showPreview()
}
}
// Nothing to do
if (!this.imgs?.length) return
if (this.animatedImages) {
removeCanvasImagePreview(this)
showAnimatedPreview(this)
} else {
removeAnimatedPreview(this)
showCanvasImagePreview(this)
}
}
node.prototype.onDrawBackground = function () { node.prototype.onDrawBackground = function () {
try { updatePreviews(this)
unsafeDrawBackground.call(this)
} catch (error) {
console.error('Error drawing node background', error)
}
} }
} }
@@ -1036,6 +1054,7 @@ export const useLitegraphService = () => {
getCanvasCenter, getCanvasCenter,
goToNode, goToNode,
resetView, resetView,
fitView fitView,
updatePreviews
} }
} }

View File

@@ -3,7 +3,6 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { import type {
ExecutedWsMessage, ExecutedWsMessage,
@@ -38,7 +37,7 @@ interface SetOutputOptions {
} }
export const useNodeOutputStore = defineStore('nodeOutput', () => { export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId } = useWorkflowStore() const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore() const { executionIdToNodeLocatorId } = useExecutionStore()
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {} const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
@@ -63,11 +62,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function getNodeOutputs( function getNodeOutputs(
node: LGraphNode node: LGraphNode
): ExecutedWsMessage['output'] | undefined { ): ExecutedWsMessage['output'] | undefined {
return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)] return app.nodeOutputs[nodeToNodeLocatorId(node)]
} }
function getNodePreviews(node: LGraphNode): string[] | undefined { function getNodePreviews(node: LGraphNode): string[] | undefined {
return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)] return app.nodePreviewImages[nodeToNodeLocatorId(node)]
} }
/** /**
@@ -161,10 +160,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) { ) {
if (!filenames || !node) return if (!filenames || !node) return
const locatorId = const locatorId = nodeToNodeLocatorId(node)
node.graph instanceof Subgraph
? nodeIdToNodeLocatorId(node.id, node.graph ?? undefined)
: `${node.id}`
if (!locatorId) return if (!locatorId) return
if (typeof filenames === 'string') { if (typeof filenames === 'string') {
setOutputsByLocatorId( setOutputsByLocatorId(

View File

@@ -134,6 +134,7 @@ LiteGraphGlobal {
"WIDGET_BGCOLOR": "#222", "WIDGET_BGCOLOR": "#222",
"WIDGET_DISABLED_TEXT_COLOR": "#666", "WIDGET_DISABLED_TEXT_COLOR": "#666",
"WIDGET_OUTLINE_COLOR": "#666", "WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF",
"WIDGET_SECONDARY_TEXT_COLOR": "#999", "WIDGET_SECONDARY_TEXT_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#DDD", "WIDGET_TEXT_COLOR": "#DDD",
"allow_multi_output_for_events": true, "allow_multi_output_for_events": true,

View File

@@ -1,21 +1,30 @@
import { describe, expect, test, vi } from 'vitest' import { describe, expect, test, vi } from 'vitest'
import '@/core/graph/subgraph/proxyWidget' import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
//import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget' import {
type LGraphCanvas,
import { LGraphNode, type SubgraphNode } from '@/lib/litegraph/src/litegraph' LGraphNode,
type SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { import {
createTestSubgraph, createTestSubgraph,
createTestSubgraphNode createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers' } from '../litegraph/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', () => ({ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({}) useCanvasStore: () => ({})
})) }))
vi.mock('@/stores/domWidgetStore', () => ({ vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() }) useDomWidgetStore: () => ({ widgetStates: new Map() })
})) }))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph( function setupSubgraph(
innerNodeCount: number = 0 innerNodeCount: number = 0
@@ -38,22 +47,20 @@ describe('Subgraph proxyWidgets', () => {
test('Can add simple widget', () => { test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1) const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual([
['1', 'stringWidget'] ['1', 'stringWidget']
]) ])
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.properties.proxyWidgets).toBe(
JSON.stringify([['1', 'stringWidget']])
)
}) })
test('Can add multiple widgets with same name', () => { test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2) const [subgraphNode, innerNodes] = setupSubgraph(2)
for (const innerNode of innerNodes) for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {}) innerNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [
['1', 'stringWidget'], ['1', 'stringWidget'],
['2', 'stringWidget'] ['2', 'stringWidget']
]) ]
expect(subgraphNode.widgets.length).toBe(2) expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).not.toEqual( expect(subgraphNode.widgets[0].name).not.toEqual(
subgraphNode.widgets[1].name subgraphNode.widgets[1].name
@@ -63,19 +70,15 @@ describe('Subgraph proxyWidgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1) const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.addWidget('text', 'stringWidget', 'value', () => {}) subgraphNode.addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
['1', 'stringWidget']
])
expect(subgraphNode.widgets.length).toBe(2) expect(subgraphNode.widgets.length).toBe(2)
subgraphNode.properties.proxyWidgets = JSON.stringify([]) subgraphNode.properties.proxyWidgets = []
expect(subgraphNode.widgets.length).toBe(1) expect(subgraphNode.widgets.length).toBe(1)
}) })
test('Will mirror changes to value', () => { test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1) const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
['1', 'stringWidget']
])
expect(subgraphNode.widgets.length).toBe(1) expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value') expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test' innerNodes[0].widgets![0].value = 'test'
@@ -86,9 +89,7 @@ describe('Subgraph proxyWidgets', () => {
test('Will not modify position or sizing of existing widgets', () => { test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1) const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
['1', 'stringWidget']
])
if (!innerNodes[0].widgets) throw new Error('node has no widgets') if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10 innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11 innerNodes[0].widgets[0].last_y = 11
@@ -103,9 +104,7 @@ describe('Subgraph proxyWidgets', () => {
test('Can detach and re-attach widgets', () => { test('Can detach and re-attach widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1) const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
subgraphNode.properties.proxyWidgets = JSON.stringify([ subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']]
['1', 'stringWidget']
])
if (!innerNodes[0].widgets) throw new Error('node has no widgets') if (!innerNodes[0].widgets) throw new Error('node has no widgets')
expect(subgraphNode.widgets[0].value).toBe('value') expect(subgraphNode.widgets[0].value).toBe('value')
const poppedWidget = innerNodes[0].widgets.pop() const poppedWidget = innerNodes[0].widgets.pop()