Files
ComfyUI_frontend/src/components/rightSidePanel/shared.ts
Rizumu Ayaka b1b2fd8a4f 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>
2026-01-13 20:37:17 -07:00

272 lines
8.0 KiB
TypeScript

import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null
> = Symbol('getNodeParentGroup')
export type NodeWidgetsList = Array<{ node: LGraphNode; widget: IBaseWidget }>
export type NodeWidgetsListList = Array<{
node: LGraphNode
widgets: NodeWidgetsList
}>
/**
* Searches widgets in a list and returns search results.
* Filters by name, localized label, type, and user-input value.
* Performs basic tokenization of the query string.
*/
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
list: T,
query: string
): T {
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
return list.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)
)
}) as T
}
/**
* Searches widgets and nodes in a list and returns search results.
* First checks if the node title matches the query (if so, keeps entire node).
* Otherwise, filters widgets using searchWidgets.
* Performs basic tokenization of the query string.
*/
export function searchWidgetsAndNodes(
list: NodeWidgetsListList,
query: string
): NodeWidgetsListList {
if (query.trim() === '') {
return list
}
const words = query.trim().toLowerCase().split(' ')
return list
.map((item) => {
const { node } = item
const title = node.getTitle().toLowerCase()
if (words.every((word) => title.includes(word))) {
return { ...item, keep: true }
}
return {
...item,
keep: false,
widgets: searchWidgets(item.widgets, query)
}
})
.filter((item) => item.keep || item.widgets.length > 0)
}
type MixedSelectionItem = LGraphGroup | LGraphNode
type FlatAndCategorizeSelectedItemsResult = {
all: MixedSelectionItem[]
nodes: LGraphNode[]
groups: LGraphGroup[]
others: Positionable[]
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
}
type FlatItemsContext = {
nodeToParentGroup: Map<LGraphNode, LGraphGroup>
depth: number
parentGroup?: LGraphGroup
}
/**
* The selected items may contain "Group" nodes, which can include child nodes.
* This function flattens such structures and categorizes items into:
* - all: all categorizable nodes (does not include nodes in "others")
* - nodes: node items
* - groups: group items
* - others: items not currently supported
* - nodeToParentGroup: a map from each node to its direct parent group (if any)
* @param items The selected items to flatten and categorize
* @returns An object containing arrays: all, nodes, groups, others, and nodeToParentGroup map
*/
export function flatAndCategorizeSelectedItems(
items: Positionable[]
): FlatAndCategorizeSelectedItemsResult {
const ctx: FlatItemsContext = {
nodeToParentGroup: new Map<LGraphNode, LGraphGroup>(),
depth: 0
}
const { all, nodes, groups, others } = flatItems(items, ctx)
return {
all: repeatItems(all),
nodes: repeatItems(nodes),
groups: repeatItems(groups),
others: repeatItems(others),
nodeToParentGroup: ctx.nodeToParentGroup
}
}
export function useFlatAndCategorizeSelectedItems(
items: MaybeRefOrGetter<Positionable[]>
) {
const result = computed(() => flatAndCategorizeSelectedItems(toValue(items)))
return {
flattedItems: computed(() => result.value.all),
selectedNodes: computed(() => result.value.nodes),
selectedGroups: computed(() => result.value.groups),
selectedOthers: computed(() => result.value.others),
nodeToParentGroup: computed(() => result.value.nodeToParentGroup)
}
}
function flatItems(
items: Positionable[],
ctx: FlatItemsContext
): Omit<FlatAndCategorizeSelectedItemsResult, 'nodeToParentGroup'> {
const result: MixedSelectionItem[] = []
const nodes: LGraphNode[] = []
const groups: LGraphGroup[] = []
const others: Positionable[] = []
if (ctx.depth > 1000) {
return {
all: [],
nodes: [],
groups: [],
others: []
}
}
for (let i = 0; i < items.length; i++) {
const item = items[i] as Positionable
if (isLGraphGroup(item)) {
result.push(item)
groups.push(item)
const children = Array.from(item.children)
const childCtx: FlatItemsContext = {
nodeToParentGroup: ctx.nodeToParentGroup,
depth: ctx.depth + 1,
parentGroup: item
}
const {
all: childAll,
nodes: childNodes,
groups: childGroups,
others: childOthers
} = flatItems(children, childCtx)
result.push(...childAll)
nodes.push(...childNodes)
groups.push(...childGroups)
others.push(...childOthers)
} else if (isLGraphNode(item)) {
result.push(item)
nodes.push(item)
if (ctx.parentGroup) {
ctx.nodeToParentGroup.set(item, ctx.parentGroup)
}
} else {
// Other types of items are not supported yet
// Do not add to all
others.push(item)
}
}
return {
all: result,
nodes,
groups,
others
}
}
function repeatItems<T>(items: T[]): T[] {
const itemSet = new Set<T>()
const result: T[] = []
for (const item of items) {
if (itemSet.has(item)) continue
itemSet.add(item)
result.push(item)
}
return result
}
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and proxy widgets in subgraphs.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
parents?: SubgraphNode[]
): boolean {
// For proxy widgets in subgraphs, we need to rename the original interior widget
if (isProxyWidget(widget) && parents?.length) {
const subgraph = parents[0].subgraph
if (!subgraph) {
console.error('Could not find subgraph for proxy widget')
return false
}
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
return false
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return false
}
// Rename the original widget
originalWidget.label = newLabel || undefined
// Also rename the corresponding input on the interior node
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === widget._overlay.widgetName
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// Always rename the widget on the current node (either regular widget or proxy widget)
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
// Intentionally mutate the widget object here as it's a reference
// to the actual widget in the graph
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
}
return true
}