Files
ComfyUI_frontend/src/core/graph/subgraph/SubgraphNode.vue
2025-09-27 16:19:03 -05:00

289 lines
8.5 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import SearchBox from '@/components/common/SearchBox.vue'
import SubgraphNodeWidget from '@/core/graph/subgraph/SubgraphNodeWidget.vue'
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 { useDialogStore } from '@/stores/dialogStore'
type WidgetItem = [LGraphNode, IBaseWidget]
const { t } = useI18n()
const canvasStore = useCanvasStore()
const searchQuery = ref<string>('')
const triggerUpdate = ref(0)
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
}
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
useDialogStore().closeDialog()
return undefined
})
const activeWidgets = computed<WidgetItem[]>({
get() {
if (triggerUpdate.value < 0) console.log('unreachable')
const node = activeNode.value
if (!node) return []
const pw = parseProxyWidgets(node.properties.proxyWidgets)
return pw.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)
throw new Error('Attempted to toggle widgets with no node selected')
//map back to id/name
const pw: ProxyWidgetsProperty = value.map(([node, widget]) => [
`${node.id}`,
widget.name
])
node.properties.proxyWidgets = JSON.stringify(pw)
//force trigger an update
triggerUpdate.value++
}
})
function toggleVisibility(
nodeId: string,
widgetName: string,
isShown: boolean
) {
const node = activeNode.value
if (!node)
throw new Error('Attempted to toggle widgets with no node selected')
if (!isShown) {
const proxyWidgets = parseProxyWidgets(node.properties.proxyWidgets)
proxyWidgets.push([nodeId, widgetName])
node.properties.proxyWidgets = JSON.stringify(proxyWidgets)
} else {
let pw = parseProxyWidgets(node.properties.proxyWidgets)
pw = pw.filter(
(p: [string, string]) => p[1] !== widgetName || p[0] !== nodeId
)
node.properties.proxyWidgets = JSON.stringify(pw)
}
triggerUpdate.value++
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
if (!n.widgets) return []
return n.widgets.map((w: IBaseWidget) => [n, w])
}
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
if (triggerUpdate.value < 0) console.log('unreachable')
const pw = parseProxyWidgets(node.properties.proxyWidgets)
const interiorNodes = node.subgraph.nodes
//node.widgets ??= []
const allWidgets: WidgetItem[] = interiorNodes.flatMap(nodeWidgets)
const filteredWidgets = allWidgets
//widget has connected link. Should not be displayed
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
.filter(
([n, w]: WidgetItem) =>
!pw.some(([pn, pw]: [string, string]) => n.id == pn && w.name == pw)
)
return filteredWidgets
})
const filteredCandidates = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return candidateWidgets.value
return candidateWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
function showAll() {
const node = activeNode.value
if (!node) return //Not reachable
const pw = parseProxyWidgets(node.properties.proxyWidgets)
const toAdd: ProxyWidgetsProperty = filteredCandidates.value.map(
([n, w]: WidgetItem) => [`${n.id}`, w.name]
)
pw.push(...toAdd)
node.properties.proxyWidgets = JSON.stringify(pw)
triggerUpdate.value++
}
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
const toKeep: ProxyWidgetsProperty = parseProxyWidgets(
node.properties.proxyWidgets
).filter(
([nodeId, widgetName]) =>
!filteredActive.value.some(
([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
)
)
node.properties.proxyWidgets = JSON.stringify(toKeep)
triggerUpdate.value++
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
const recommendedWidgets = computed(() => {
const node = activeNode.value
if (!node) return [] //Not reachable
return filteredCandidates.value.filter(
([node, widget]: WidgetItem) =>
recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name)
)
})
function showRecommended() {
const node = activeNode.value
if (!node) return //Not reachable
const pw = parseProxyWidgets(node.properties.proxyWidgets)
const toAdd: ProxyWidgetsProperty = recommendedWidgets.value.map(
([n, w]: WidgetItem) => [`${n.id}`, w.name]
)
//TODO: Add sort step here
//Input should always be before output by default
pw.push(...toAdd)
node.properties.proxyWidgets = JSON.stringify(pw)
triggerUpdate.value++
}
const filteredActive = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
</script>
<template>
<SearchBox
v-model:model-value="searchQuery"
class="model-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.search') + '...'"
/>
<div v-if="filteredActive.length" class="widgets-section">
<div class="widgets-section-header">
<div>{{ t('subgraphStore.shown') }}</div>
<a @click.stop="hideAll"> {{ t('subgraphStore.hideAll') }}</a>
</div>
<div v-if="searchQuery" class="w-full">
<div
v-for="element in filteredActive"
:key="toKey(element)"
class="w-full"
>
<SubgraphNodeWidget
:node-id="`${element[0].id}`"
:node-title="element[0].title"
:widget-name="element[1].name"
:toggle-visibility="toggleVisibility"
:is-shown="true"
/>
</div>
</div>
<draggable
v-else
v-model="activeWidgets"
group="enabledWidgets"
class="w-full cursor-grab"
chosen-class="cursor-grabbing"
drag-class="cursor-grabbing"
:animation="100"
item-key="id"
>
<template #item="{ element }">
<SubgraphNodeWidget
:node-id="`${element[0].id}`"
:node-title="element[0].title"
:widget-name="element[1].name"
:is-shown="true"
:toggle-visibility="toggleVisibility"
:is-draggable="true"
/>
</template>
</draggable>
</div>
<div v-if="filteredCandidates.length" class="widgets-section">
<div class="widgets-section-header">
<div>{{ t('subgraphStore.hidden') }}</div>
<a @click.stop="showAll"> {{ t('subgraphStore.showAll') }}</a>
</div>
<div
v-for="element in filteredCandidates"
:key="toKey(element)"
class="w-full"
>
<SubgraphNodeWidget
:node-id="`${element[0].id}`"
:node-title="element[0].title"
:widget-name="element[1].name"
:toggle-visibility="toggleVisibility"
/>
</div>
</div>
<div v-if="recommendedWidgets.length" class="justify-center flex py-4">
<Button size="small" @click.stop="showRecommended">
{{ t('subgraphStore.showRecommended') }}
</Button>
</div>
</template>
<style scoped>
.widgets-section-header {
display: flex;
padding: 0 16px;
justify-content: space-between;
}
.widgets-section-header div {
color: var(--color-slate-100, #9c9eab);
/* body-text-badge */
font-family: Inter;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
a {
cursor: pointer;
color: var(--color-blue-100, #0b8ce9);
text-align: right;
/* body-text-caption */
font-family: Inter;
font-size: 11px;
font-weight: 400;
}
.widgets-section {
padding: 4px 0 16px 0;
border-bottom: 1px solid var(--color-node-divider, #2e3037);
}
</style>