mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +00:00
289 lines
8.5 KiB
Vue
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>
|