mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 10:00:08 +00:00
feat: right side panel favorites, no selection state, and more... (#7812)
Most of the features in this pull request are completed and can be
reviewed and merged.
## TODO
- [x] no selection panel
- [x] group selected panel
- [x] tabs
- [x] favorites tab
- [x] global settings tab
- [x] nodes tab
- [x] widget actions menu
- [x] [Bug]: style bugs
- [x] button zoom to the node on canvas.
- [x] rename widgets on widget actions
- [ ] [Bug]: the canvas has not been updated after renaming.
- [x] global settings
- [ ] setting item: "show advanced parameters"
- blocked by other things. skip for now.
- [x] setting item: show toolbox on selection
- [x] setting item: nodes 2.0
- [ ] setting item: "background color"
- blocked by other things. skip for now.
- [x] setting item: grid spacing
- [x] setting item: snap nodes to grid
- [x] setting item: link shape
- [x] setting item: show connected links
- [x] form style reuses the form style of node widgets
- [x] group node cases
- [x] group node settings
- [x] show all nodes in group
- [x] show frame name on nodes when multiple selections are made
- [x] group multiple selections
- [x] [Bug]: nodes without widgets cannot display the location and their
group
- [x] [Bug]: labels layout
- [x] favorites
- [x] the indicator on widgets
- [x] favorite and unfavorite buttons on widgets
- [x] [Bug]: show node name in favorite widgets + improve labels layout
- [ ] [Bug]: After canceling the like, the like list will not be updated
immediately.
- [x] [Bug]: The favorite function does not work for the project on
Subgraph.
- [x] subgraph
- [x] add the node name from where this parameter comes from when node
is subgraph
- [x] show and hide directly on Inputs
- [x] some bugs need to be fixed.
- [x] advanced widgets
- [x] button: show advanced inputs
- Clicking button expands the "Advanced Inputs" section on the right
side panel, regardless of whether the panel is open or not
- [x] [Bug]: style bugs
- [x] advanced inputs section when node is subgraph
- [x] inputs tab rearranging
- [x] favorited inputs rearranging
- [x] subgraph inputs rearranging
- [ ] review and reconstruction to improve complexity and architecture
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7812-feat-right-side-panel-favorites-no-selection-state-and-more-2da6d73d36508134b503d676f9b3d248)
by [Unito](https://www.unito.io)
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
@@ -1,87 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { label, widgets } = defineProps<{
|
||||
const {
|
||||
label,
|
||||
node,
|
||||
widgets: widgetsProp,
|
||||
showLocateButton = false,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
enableEmptyState = false,
|
||||
tooltip
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
parents?: SubgraphNode[]
|
||||
node?: LGraphNode
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
showLocateButton?: boolean
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
/**
|
||||
* Whether to show the empty state slot when there are no widgets.
|
||||
*/
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const widgetsContainer = ref<HTMLElement>()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
provide('hideLayoutField', true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function getWidgetComponent(widget: IBaseWidget) {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// For regular widgets (not yet promoted), check using node ID and widget name
|
||||
return proxyWidgets.some(
|
||||
([nodeId, widgetName]) =>
|
||||
widgetNode.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
}
|
||||
|
||||
function onWidgetValueChange(
|
||||
widget: IBaseWidget,
|
||||
value: string | number | boolean | object
|
||||
) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.length === 0)
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
const displayLabel = computed(
|
||||
() =>
|
||||
label ??
|
||||
(isEmpty.value
|
||||
? t('rightSidePanel.inputsNone')
|
||||
: t('rightSidePanel.inputs'))
|
||||
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
||||
)
|
||||
|
||||
const targetNode = computed<LGraphNode | null>(() => {
|
||||
if (node) return node
|
||||
if (isEmpty.value) return null
|
||||
|
||||
const firstNodeId = widgets.value[0].node.id
|
||||
const allSameNode = widgets.value.every(({ node }) => node.id === firstNodeId)
|
||||
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
})
|
||||
|
||||
const canShowLocateButton = computed(
|
||||
() => showLocateButton && targetNode.value !== null
|
||||
)
|
||||
|
||||
function handleLocateNode() {
|
||||
if (!targetNode.value || !canvasStore.canvas) return
|
||||
|
||||
const graphNode = canvasStore.canvas.graph?.getNodeById(targetNode.value.id)
|
||||
if (graphNode) {
|
||||
canvasStore.canvas.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
widgetsContainer,
|
||||
rootElement
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PropertiesAccordionItem :is-empty>
|
||||
<template #label>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div v-if="!isEmpty" class="space-y-4 rounded-lg bg-interface-surface px-4">
|
||||
<div
|
||||
v-for="({ widget, node }, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="widget-item gap-1.5 col-span-full grid grid-cols-subgrid"
|
||||
>
|
||||
<div class="min-h-8">
|
||||
<p v-if="widget.name" class="text-sm leading-8 p-0 m-0 line-clamp-1">
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<div ref="rootElement">
|
||||
<PropertiesAccordionItem
|
||||
v-model:collapse="collapse"
|
||||
:enable-empty-state
|
||||
:disabled="isEmpty"
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="parentGroup"
|
||||
class="text-xs text-muted-foreground truncate flex-1 text-right min-w-11"
|
||||
:title="parentGroup.title"
|
||||
>
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.locateNode')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
:model-value="useReactiveWidgetValue(widget)"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
@update:model-value="
|
||||
(value: string | number | boolean | object) =>
|
||||
onWidgetValueChange(widget, value)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #empty><slot name="empty" /></template>
|
||||
|
||||
<div
|
||||
ref="widgetsContainer"
|
||||
class="space-y-2 rounded-lg px-4 pt-1 relative"
|
||||
>
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
142
src/components/rightSidePanel/parameters/TabGlobalParameters.vue
Normal file
142
src/components/rightSidePanel/parameters/TabGlobalParameters.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
|
||||
const isSearching = ref(false)
|
||||
|
||||
const favoritedWidgets = computed(
|
||||
() => favoritedWidgetsStore.validFavoritedWidgets
|
||||
)
|
||||
|
||||
const label = computed(() =>
|
||||
favoritedWidgets.value.length === 0
|
||||
? t('rightSidePanel.favoritesNone')
|
||||
: t('rightSidePanel.favorites')
|
||||
)
|
||||
|
||||
const searchedFavoritedWidgets = shallowRef<ValidFavoritedWidget[]>(
|
||||
favoritedWidgets.value
|
||||
)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim().length > 0
|
||||
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
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 as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [widget] = widgets.splice(oldPosition, 1)
|
||||
widgets.splice(newPosition, 0, widget)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchedFavoritedWidgets,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="favoritedWidgets"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:label
|
||||
:widgets="searchedFavoritedWidgets"
|
||||
:is-draggable="!isSearching"
|
||||
hidden-favorite-indicator
|
||||
show-node-name
|
||||
enable-empty-state
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center py-10">
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.favoritesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
</template>
|
||||
82
src/components/rightSidePanel/parameters/TabNodes.vue
Normal file
82
src/components/rightSidePanel/parameters/TabNodes.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const nodes = computed((): LGraphNode[] => {
|
||||
// Depend on activeWorkflow to trigger recomputation when workflow changes
|
||||
void workflowStore.activeWorkflow?.path
|
||||
return (canvasStore.canvas?.graph?.nodes ?? []) as LGraphNode[]
|
||||
})
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.value.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="isSearching && searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{ $t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ node, widgets } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="!isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: $t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
96
src/components/rightSidePanel/parameters/TabNormalInputs.vue
Normal file
96
src/components/rightSidePanel/parameters/TabNormalInputs.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes, mustShowNodeTitle } = defineProps<{
|
||||
mustShowNodeTitle?: boolean
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
const isMultipleNodesSelected = computed(
|
||||
() => widgetsSectionDataList.value.length > 1
|
||||
)
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
const list = widgetsSectionDataList.value
|
||||
const target = searchedWidgetsSectionDataList
|
||||
isSearching.value = query.trim() !== ''
|
||||
target.value = searchWidgetsAndNodes(list, query)
|
||||
}
|
||||
|
||||
const label = computed(() => {
|
||||
const sections = widgetsSectionDataList.value
|
||||
return !mustShowNodeTitle && sections.length === 1
|
||||
? sections[0].widgets.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsSectionDataList"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="searchedWidgetsSectionDataList.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 py-10 text-center"
|
||||
>
|
||||
{{
|
||||
isSearching
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.nodesNoneDesc')
|
||||
}}
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in searchedWidgetsSectionDataList"
|
||||
:key="node.id"
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isMultipleNodesSelected && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import SidePanelSearch from '../layout/SidePanelSearch.vue'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { nodes } = defineProps<{
|
||||
nodes: LGraphNode[]
|
||||
}>()
|
||||
|
||||
type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
|
||||
type NodeWidgetsListList = Array<{
|
||||
node: LGraphNode
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>([])
|
||||
|
||||
/**
|
||||
* Searches widgets in all selected nodes and returns search results.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
async function searcher(query: string) {
|
||||
if (query.trim() === '') {
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
return
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
searchedWidgetsSectionDataList.value = widgetsSectionDataList.value
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
widgets: item.widgets.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item) => item.widgets.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<SidePanelSearch :searcher :update-key="widgetsSectionDataList" />
|
||||
</div>
|
||||
<SectionWidgets
|
||||
v-for="section in searchedWidgetsSectionDataList"
|
||||
:key="section.node.id"
|
||||
:label="widgetsSectionDataList.length > 1 ? section.node.title : undefined"
|
||||
:widgets="section.widgets"
|
||||
:default-collapse="
|
||||
widgetsSectionDataList.length > 1 &&
|
||||
widgetsSectionDataList === searchedWidgetsSectionDataList
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
244
src/components/rightSidePanel/parameters/TabSubgraphInputs.vue
Normal file
244
src/components/rightSidePanel/parameters/TabSubgraphInputs.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
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 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 { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import type { NodeWidgetsList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: SubgraphNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const advancedInputsCollapsed = ref(true)
|
||||
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
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
node.properties.proxyWidgets = value
|
||||
}
|
||||
}))
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
if (section === 'advanced-inputs') {
|
||||
advancedInputsCollapsed.value = false
|
||||
rightSidePanelStore.clearFocusedSection()
|
||||
|
||||
await nextTick()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const sectionComponent = advancedInputsSectionRef.value
|
||||
const sectionElement = sectionComponent?.rootElement
|
||||
if (sectionElement) {
|
||||
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
const proxyWidgetsOrder = proxyWidgets.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
|
||||
const widget = widgets.find((w) => {
|
||||
// Check if this is a proxy widget with _overlay
|
||||
if (isProxyWidget(w)) {
|
||||
return (
|
||||
String(w._overlay.nodeId) === nodeId &&
|
||||
w._overlay.widgetName === widgetName
|
||||
)
|
||||
}
|
||||
// For non-proxy widgets (like linked widgets), match by name
|
||||
return w.name === widgetName
|
||||
})
|
||||
if (widget) {
|
||||
result.push({ node, widget })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
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
|
||||
.filter((w) => !w.computedDisabled)
|
||||
.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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
|
||||
const searchedWidgetsList = shallowRef<NodeWidgetsList>(widgetsList.value)
|
||||
const isSearching = ref(false)
|
||||
|
||||
async function searcher(query: string) {
|
||||
isSearching.value = query.trim() !== ''
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
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
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
: t('rightSidePanel.inputsNone')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke">
|
||||
<FormSearchInput
|
||||
v-model="searchQuery"
|
||||
:searcher
|
||||
:update-key="widgetsList"
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:node
|
||||
:label
|
||||
:parents
|
||||
:widgets="searchedWidgetsList"
|
||||
:is-draggable="!isSearching"
|
||||
:enable-empty-state="isSearching"
|
||||
:tooltip="
|
||||
isSearching || searchedWidgetsList.length
|
||||
? ''
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="nextTick(setDraggableState)"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15">
|
||||
{{ t('rightSidePanel.noneSearchDesc') }}
|
||||
</div>
|
||||
</template>
|
||||
</SectionWidgets>
|
||||
<SectionWidgets
|
||||
v-if="advancedInputsWidgets.length > 0"
|
||||
ref="advancedInputsSectionRef"
|
||||
v-model:collapse="advancedInputsCollapsed"
|
||||
:label="t('rightSidePanel.advancedInputs')"
|
||||
:parents="parents"
|
||||
:widgets="advancedInputsWidgets"
|
||||
show-node-name
|
||||
class="border-b border-interface-stroke"
|
||||
/>
|
||||
</template>
|
||||
167
src/components/rightSidePanel/parameters/WidgetActions.vue
Normal file
167
src/components/rightSidePanel/parameters/WidgetActions.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import {
|
||||
demoteWidget,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
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'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName') + ':',
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
}
|
||||
|
||||
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')
|
||||
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)
|
||||
} else {
|
||||
// For regular widgets (not yet promoted), use them directly
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
promoteWidget(node, widget, parents)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MoreButton
|
||||
is-vertical
|
||||
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleRename()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--edit] size-4" />
|
||||
<span>{{ t('g.rename') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasParents"
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
else handleShowInput()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isShownOnParents">
|
||||
<i class="icon-[lucide--eye-off] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideInput') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleFavorite()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isFavorited">
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--star]" />
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
183
src/components/rightSidePanel/parameters/WidgetItem.vue
Normal file
183
src/components/rightSidePanel/parameters/WidgetItem.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, customRef, ref } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
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'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { renameWidget } from '../shared'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
const enhancedWidget = computed(() => {
|
||||
// Get shared enhancements (reactive value, controlWidget, spec, nodeType, etc.)
|
||||
const enhancements = getSharedWidgetEnhancements(node, widget)
|
||||
return { ...widget, ...enhancements }
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
let sourceNode: LGraphNode | null = node
|
||||
if (isProxyWidget(widget)) {
|
||||
const { graph, nodeId } = widget._overlay
|
||||
sourceNode = getNodeByExecutionId(graph, nodeId)
|
||||
}
|
||||
return sourceNode ? sourceNode.title || sourceNode.type : null
|
||||
})
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
const displayLabel = customRef((track, trigger) => {
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return widget.label || widget.name
|
||||
},
|
||||
set(newValue: string) {
|
||||
isEditing.value = false
|
||||
|
||||
const trimmedLabel = newValue.trim()
|
||||
|
||||
const success = renameWidget(widget, node, trimmedLabel, parents)
|
||||
|
||||
if (success) {
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'widget-item col-span-full grid grid-cols-subgrid rounded-lg group',
|
||||
isDraggable &&
|
||||
'draggable-item !will-change-auto drag-handle cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing outline-comfy-menu-bg [&.is-draggable]:outline-4 [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- widget header -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0',
|
||||
isDraggable && 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<EditableText
|
||||
v-if="widget.name"
|
||||
:model-value="displayLabel"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ placeholder: widget.name }"
|
||||
class="text-sm leading-8 p-0 m-0 truncate pointer-events-auto cursor-text"
|
||||
@edit="displayLabel = $event"
|
||||
@cancel="isEditing = false"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
class="text-xs text-muted-foreground flex-1 p-0 my-0 mx-1 truncate text-right min-w-10"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- favorite indicator -->
|
||||
<div
|
||||
v-if="
|
||||
!hiddenFavoriteIndicator &&
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode, widget.name)
|
||||
"
|
||||
class="relative z-2 pointer-events-none"
|
||||
>
|
||||
<i
|
||||
class="absolute -right-1 -top-1 pi pi-star-fill text-xs text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<!-- widget content -->
|
||||
<component
|
||||
:is="widgetComponent"
|
||||
v-model="widgetValue"
|
||||
:widget="enhancedWidget"
|
||||
:node-id="String(node.id)"
|
||||
:node-type="node.type"
|
||||
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"
|
||||
/>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none mt-1.5 mx-auto max-w-40 w-1/2 h-1 rounded-lg bg-transparent transition-colors duration-150',
|
||||
'group-hover:bg-interface-stroke group-[.is-draggable]:bg-component-node-widget-background-highlighted',
|
||||
!isDraggable && 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user