mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 05:19:53 +00:00
Group dynamic widgets together for drag operations
This commit is contained in:
@@ -14,7 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import { GetNodeParentGroupKey, getWidgetGroupKey } from '../shared'
|
||||
import WidgetGroup from './WidgetGroup.vue'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const {
|
||||
@@ -84,6 +85,34 @@ function isWidgetShownOnParents(
|
||||
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
type WidgetEntry = { widget: IBaseWidget; node: LGraphNode }
|
||||
type WidgetGroup = {
|
||||
key: string
|
||||
items: WidgetEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group widgets by their group key (for dynamic widget grouping).
|
||||
* Widgets with the same group key are placed together in a single group.
|
||||
*/
|
||||
const groupedWidgets = computed((): WidgetGroup[] => {
|
||||
const groups: WidgetGroup[] = []
|
||||
const keyToGroup = new Map<string, WidgetGroup>()
|
||||
|
||||
for (const entry of widgets.value) {
|
||||
const key = getWidgetGroupKey(entry.widget)
|
||||
let group = keyToGroup.get(key)
|
||||
if (!group) {
|
||||
group = { key, items: [] }
|
||||
keyToGroup.set(key, group)
|
||||
groups.push(group)
|
||||
}
|
||||
group.items.push(entry)
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
const displayLabel = computed(
|
||||
() => label ?? (node ? node.title : t('rightSidePanel.inputs'))
|
||||
)
|
||||
@@ -167,17 +196,22 @@ defineExpose({
|
||||
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"
|
||||
<WidgetGroup
|
||||
v-for="group in groupedWidgets"
|
||||
:key="group.key"
|
||||
:is-draggable="isDraggable"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
/>
|
||||
>
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in group.items"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
/>
|
||||
</WidgetGroup>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgets } from '../shared'
|
||||
import { getWidgetGroupKey, searchWidgets } from '../shared'
|
||||
import type { NodeWidgetsList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
@@ -103,6 +103,57 @@ const widgetsList = computed((): NodeWidgetsList => {
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the group key for a widget by its proxyWidgets entry.
|
||||
* Returns the parent widget name if this is a child, otherwise the widget's own name.
|
||||
*/
|
||||
function getGroupKeyForEntry(widgetName: string): string {
|
||||
const { widgets = [] } = node
|
||||
|
||||
// Find the actual widget to check dynamicWidgetParent
|
||||
const widget = widgets.find((w) => {
|
||||
if (isProxyWidget(w)) {
|
||||
return w._overlay.widgetName === widgetName
|
||||
}
|
||||
return w.name === widgetName
|
||||
})
|
||||
|
||||
if (widget) {
|
||||
return getWidgetGroupKey(widget)
|
||||
}
|
||||
return widgetName
|
||||
}
|
||||
|
||||
type ProxyWidgetGroup = {
|
||||
key: string
|
||||
indices: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of groups from proxyWidgets.
|
||||
* Each group contains the indices of widgets that belong together.
|
||||
* Groups are ordered by the first occurrence of their members.
|
||||
*/
|
||||
function buildProxyWidgetGroups(pw: [string, string][]): ProxyWidgetGroup[] {
|
||||
const groups: ProxyWidgetGroup[] = []
|
||||
const keyToGroup = new Map<string, ProxyWidgetGroup>()
|
||||
|
||||
for (let i = 0; i < pw.length; i++) {
|
||||
const [, widgetName] = pw[i]
|
||||
const key = getGroupKeyForEntry(widgetName)
|
||||
|
||||
let group = keyToGroup.get(key)
|
||||
if (!group) {
|
||||
group = { key, indices: [] }
|
||||
keyToGroup.set(key, group)
|
||||
groups.push(group)
|
||||
}
|
||||
group.indices.push(i)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
const interiorNodes = node.subgraph.nodes
|
||||
const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets)
|
||||
@@ -178,11 +229,42 @@ function setDraggableState() {
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
// Update proxyWidgets order
|
||||
// Build groups from proxyWidgets
|
||||
// Each draggable item corresponds to a group (container or single widget)
|
||||
const pw = proxyWidgets.value
|
||||
const [w] = pw.splice(oldPosition, 1)
|
||||
pw.splice(newPosition, 0, w)
|
||||
proxyWidgets.value = pw
|
||||
const groups = buildProxyWidgetGroups(pw)
|
||||
|
||||
if (oldPosition >= groups.length || newPosition >= groups.length) {
|
||||
console.error('[TabSubgraphInputs] position out of bounds')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the group being moved
|
||||
const movedGroup = groups[oldPosition]
|
||||
const movedIndices = movedGroup.indices
|
||||
|
||||
// Extract the entries being moved (in their original order)
|
||||
const movedEntries: [string, string][] = movedIndices.map((i) => pw[i])
|
||||
|
||||
const newPw: [string, string][] = []
|
||||
const reorderedGroups = [...groups]
|
||||
reorderedGroups.splice(oldPosition, 1)
|
||||
reorderedGroups.splice(newPosition, 0, movedGroup)
|
||||
|
||||
// Flatten back to proxyWidgets, preserving entry order within each group
|
||||
for (const group of reorderedGroups) {
|
||||
if (group === movedGroup) {
|
||||
// Use the entries we extracted earlier
|
||||
newPw.push(...movedEntries)
|
||||
} else {
|
||||
// Add entries from this group in their original order
|
||||
for (const idx of group.indices) {
|
||||
newPw.push(pw[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proxyWidgets.value = newPw
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
triggerRef(proxyWidgets)
|
||||
}
|
||||
|
||||
26
src/components/rightSidePanel/parameters/WidgetGroup.vue
Normal file
26
src/components/rightSidePanel/parameters/WidgetGroup.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isDraggable = false } = defineProps<{
|
||||
isDraggable?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'widget-group rounded-lg',
|
||||
isDraggable &&
|
||||
'draggable-item drag-handle group cursor-grab bg-comfy-menu-bg [&.is-draggable]:cursor-grabbing [&.is-draggable]:outline-4 [&.is-draggable]:outline-comfy-menu-bg [&.is-draggable]:outline-offset-0 [&.is-draggable]:opacity-70 [&_.widget-item-header]:pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<!-- Drag handle indicator -->
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
class="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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,7 +23,6 @@ import WidgetActions from './WidgetActions.vue'
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
@@ -31,7 +30,6 @@ const {
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
@@ -104,22 +102,11 @@ const displayLabel = customRef((track, trigger) => {
|
||||
|
||||
<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'
|
||||
)
|
||||
"
|
||||
class="widget-item col-span-full grid grid-cols-subgrid rounded-lg group drag-handle"
|
||||
>
|
||||
<!-- 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'
|
||||
)
|
||||
"
|
||||
class="widget-item-header min-h-8 flex items-center justify-between gap-1 mb-1.5 min-w-0"
|
||||
>
|
||||
<EditableText
|
||||
v-if="widget.name"
|
||||
@@ -169,15 +156,5 @@ const displayLabel = customRef((track, trigger) => {
|
||||
: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>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
|
||||
import {
|
||||
flatAndCategorizeSelectedItems,
|
||||
getWidgetGroupKey,
|
||||
searchWidgets
|
||||
} from './shared'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('searchWidgets', () => {
|
||||
@@ -188,3 +192,109 @@ describe('flatAndCategorizeSelectedItems', () => {
|
||||
expect(result.all).not.toContain(unknownItem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWidgetGroupKey', () => {
|
||||
it('should return parent name for child widgets', () => {
|
||||
const widget = {
|
||||
name: 'dynamic_combo.w1',
|
||||
type: 'number',
|
||||
dynamicWidgetParent: 'dynamic_combo'
|
||||
} as IBaseWidget
|
||||
|
||||
expect(getWidgetGroupKey(widget)).toBe('dynamic_combo')
|
||||
})
|
||||
|
||||
it('should return widget name for parent widgets (dynamic combo roots)', () => {
|
||||
const widget = {
|
||||
name: 'dynamic_combo',
|
||||
type: 'combo',
|
||||
dynamicWidgetRoot: true
|
||||
} as IBaseWidget
|
||||
|
||||
expect(getWidgetGroupKey(widget)).toBe('dynamic_combo')
|
||||
})
|
||||
|
||||
it('should return widget name for regular widgets', () => {
|
||||
const widget = {
|
||||
name: 'regular_widget',
|
||||
type: 'number'
|
||||
} as IBaseWidget
|
||||
|
||||
expect(getWidgetGroupKey(widget)).toBe('regular_widget')
|
||||
})
|
||||
|
||||
it('should return widget name if dynamicWidgetParent is empty string', () => {
|
||||
const widget = {
|
||||
name: 'some_widget',
|
||||
type: 'number',
|
||||
dynamicWidgetParent: ''
|
||||
} as IBaseWidget
|
||||
|
||||
// Empty string is falsy, so widget is treated as its own group
|
||||
expect(getWidgetGroupKey(widget)).toBe('some_widget')
|
||||
})
|
||||
|
||||
it('should use _overlay.widgetName for proxy widgets (parent)', () => {
|
||||
// Proxy widgets have names with node ID prefix like "1: dynamic_combo"
|
||||
const proxyWidget = {
|
||||
name: '1: dynamic_combo',
|
||||
type: 'combo',
|
||||
dynamicWidgetRoot: true,
|
||||
_overlay: { widgetName: 'dynamic_combo', nodeId: '1' }
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
// Should return base name so it matches children's dynamicWidgetParent
|
||||
expect(getWidgetGroupKey(proxyWidget)).toBe('dynamic_combo')
|
||||
})
|
||||
|
||||
it('should group proxy parent and children together', () => {
|
||||
// Parent proxy widget
|
||||
const parentProxy = {
|
||||
name: '1: dynamic_combo',
|
||||
type: 'combo',
|
||||
dynamicWidgetRoot: true,
|
||||
_overlay: { widgetName: 'dynamic_combo', nodeId: '1' }
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
// Child proxy widget
|
||||
const childProxy = {
|
||||
name: '1: dynamic_combo.w1',
|
||||
type: 'number',
|
||||
dynamicWidgetParent: 'dynamic_combo',
|
||||
_overlay: { widgetName: 'dynamic_combo.w1', nodeId: '1' }
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
// Both should return 'dynamic_combo' so they're in the same group
|
||||
expect(getWidgetGroupKey(parentProxy)).toBe('dynamic_combo')
|
||||
expect(getWidgetGroupKey(childProxy)).toBe('dynamic_combo')
|
||||
})
|
||||
|
||||
it('should group disconnected child widgets using overlay.dynamicWidgetParent', () => {
|
||||
// Disconnected child widget - dynamicWidgetParent is stored in overlay
|
||||
// because the backing widget (disconnectedWidget) doesn't have this property
|
||||
const disconnectedChild = {
|
||||
name: '1: dynamic_combo.child_widget',
|
||||
type: 'button', // disconnectedWidget type
|
||||
// No dynamicWidgetParent on widget itself
|
||||
_overlay: {
|
||||
widgetName: 'dynamic_combo.child_widget',
|
||||
nodeId: '1',
|
||||
dynamicWidgetParent: 'dynamic_combo' // Stored in overlay
|
||||
}
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
// Should use overlay.dynamicWidgetParent
|
||||
expect(getWidgetGroupKey(disconnectedChild)).toBe('dynamic_combo')
|
||||
})
|
||||
|
||||
it('should not group widgets without dynamicWidgetParent in overlay', () => {
|
||||
// Regular widget without dynamicWidgetParent in widget or overlay
|
||||
const regularWidget = {
|
||||
name: 'some_widget',
|
||||
type: 'number',
|
||||
_overlay: { widgetName: 'some_widget', nodeId: '1' }
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
expect(getWidgetGroupKey(regularWidget)).toBe('some_widget')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,6 +250,41 @@ function repeatItems<T>(items: T[]): T[] {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base widget name, stripping any node ID prefix.
|
||||
* Proxy widgets on SubgraphNodes have names like "1: widgetName".
|
||||
*/
|
||||
function getBaseWidgetName(widget: IBaseWidget): string {
|
||||
// Check if it's a proxy widget with _overlay
|
||||
const overlay = (widget as { _overlay?: { widgetName?: string } })._overlay
|
||||
if (overlay?.widgetName) {
|
||||
return overlay.widgetName
|
||||
}
|
||||
return widget.name
|
||||
}
|
||||
|
||||
export function getWidgetGroupKey(widget: IBaseWidget): string {
|
||||
// Check dynamicWidgetParent on the widget (works for connected widgets)
|
||||
if (widget.dynamicWidgetParent) {
|
||||
return widget.dynamicWidgetParent
|
||||
}
|
||||
|
||||
// For proxy widgets, check the overlay for dynamicWidgetParent
|
||||
// This handles disconnected widgets where the backing widget doesn't have the property
|
||||
// as the actual widget doesn't exist, and is the disconnected widget.
|
||||
const overlay = (
|
||||
widget as {
|
||||
_overlay?: { dynamicWidgetParent?: string; widgetName?: string }
|
||||
}
|
||||
)._overlay
|
||||
if (overlay?.dynamicWidgetParent) {
|
||||
return overlay.dynamicWidgetParent
|
||||
}
|
||||
|
||||
// Use base name to match children's dynamicWidgetParent values
|
||||
return getBaseWidgetName(widget)
|
||||
}
|
||||
|
||||
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ type Overlay = Partial<IBaseWidget> & {
|
||||
hidden?: boolean
|
||||
/** Flag to trigger re-resolution when source node's widgets change */
|
||||
needsResolve?: boolean
|
||||
/** Cached dynamicWidgetParent for grouping when widget is disconnected */
|
||||
dynamicWidgetParent?: string
|
||||
}
|
||||
// A ProxyWidget can be treated like a normal widget.
|
||||
// the _overlay property can be used to directly access the Overlay object
|
||||
@@ -147,11 +149,21 @@ function newProxyWidget(
|
||||
widgetName: string
|
||||
) {
|
||||
const name = `${nodeId}: ${widgetName}`
|
||||
const overlay = {
|
||||
|
||||
// Determine dynamicWidgetParent from widget name pattern (parentName.childName)
|
||||
// This ensures grouping works even when the backing widget is disconnected
|
||||
let dynamicWidgetParent: string | undefined
|
||||
const dotIndex = widgetName.indexOf('.')
|
||||
if (dotIndex !== -1) {
|
||||
dynamicWidgetParent = widgetName.slice(0, dotIndex)
|
||||
}
|
||||
|
||||
const overlay: Overlay = {
|
||||
//items specific for proxy management
|
||||
nodeId,
|
||||
graph: subgraphNode.subgraph,
|
||||
widgetName,
|
||||
dynamicWidgetParent,
|
||||
//Items which normally exist on widgets
|
||||
afterQueued: undefined,
|
||||
computedHeight: undefined,
|
||||
@@ -177,6 +189,12 @@ function resolveLinkedWidget(
|
||||
// Slightly hacky. Force recursive resolution of nested widgets
|
||||
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
||||
widget.computedHeight = 20
|
||||
|
||||
// Cache dynamicWidgetParent in overlay for use when widget becomes disconnected
|
||||
if (widget?.dynamicWidgetParent) {
|
||||
overlay.dynamicWidgetParent = widget.dynamicWidgetParent
|
||||
}
|
||||
|
||||
return [n, widget]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user