mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +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:
133
src/composables/graph/useGraphHierarchy.test.ts
Normal file
133
src/composables/graph/useGraphHierarchy.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import * as measure from '@/lib/litegraph/src/measure'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useGraphHierarchy } from './useGraphHierarchy'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore')
|
||||
|
||||
describe('useGraphHierarchy', () => {
|
||||
let mockCanvasStore: ReturnType<typeof useCanvasStore>
|
||||
let mockNode: LGraphNode
|
||||
let mockGroups: LGraphGroup[]
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
boundingRect: [100, 100, 50, 50]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockGroups = []
|
||||
|
||||
mockCanvasStore = {
|
||||
canvas: {
|
||||
graph: {
|
||||
groups: mockGroups
|
||||
}
|
||||
}
|
||||
} as any
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore)
|
||||
})
|
||||
|
||||
describe('findParentGroup', () => {
|
||||
it('returns null when no groups exist', () => {
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when node is not in any group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 50, 50]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(false)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the only group when node is in exactly one group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 200, 200]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(group)
|
||||
})
|
||||
|
||||
it('returns the smallest group when node is in multiple groups', () => {
|
||||
const largeGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const smallGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(largeGroup, smallGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
vi.spyOn(measure, 'containsRect').mockReturnValue(false)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(smallGroup)
|
||||
})
|
||||
|
||||
it('returns the inner group when one group contains another', () => {
|
||||
const outerGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const innerGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
mockGroups.push(outerGroup, innerGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
vi.spyOn(measure, 'containsRect').mockImplementation(
|
||||
(container, contained) => {
|
||||
// outerGroup contains innerGroup
|
||||
if (container === outerGroup.boundingRect) {
|
||||
return contained === innerGroup.boundingRect
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBe(innerGroup)
|
||||
})
|
||||
|
||||
it('handles null canvas gracefully', () => {
|
||||
mockCanvasStore.canvas = null as any
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('handles null graph gracefully', () => {
|
||||
mockCanvasStore.canvas!.graph = null as any
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/composables/graph/useGraphHierarchy.ts
Normal file
57
src/composables/graph/useGraphHierarchy.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { containsCentre, containsRect } from '@/lib/litegraph/src/measure'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Composable for working with graph hierarchy, specifically group containment.
|
||||
*/
|
||||
export function useGraphHierarchy() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
/**
|
||||
* Finds the smallest group that contains the center of a node's bounding box.
|
||||
* When multiple groups contain the node, returns the one with the smallest area.
|
||||
*
|
||||
* TODO: This traverses the entire graph and could be very slow; needs optimization.
|
||||
* Consider spatial indexing or caching for large graphs.
|
||||
*
|
||||
* @param node - The node to find the parent group for
|
||||
* @returns The parent group if found, otherwise null
|
||||
*/
|
||||
function findParentGroup(node: LGraphNode): LGraphGroup | null {
|
||||
const graphGroups = (canvasStore.canvas?.graph?.groups ??
|
||||
[]) as LGraphGroup[]
|
||||
|
||||
let parent: LGraphGroup | null = null
|
||||
|
||||
for (const group of graphGroups) {
|
||||
const groupRect = group.boundingRect
|
||||
if (!containsCentre(groupRect, node.boundingRect)) continue
|
||||
|
||||
if (!parent) {
|
||||
parent = group
|
||||
continue
|
||||
}
|
||||
|
||||
const parentRect = parent.boundingRect
|
||||
const candidateInsideParent = containsRect(parentRect, groupRect)
|
||||
const parentInsideCandidate = containsRect(groupRect, parentRect)
|
||||
|
||||
if (candidateInsideParent && !parentInsideCandidate) {
|
||||
parent = group
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateArea = groupRect[2] * groupRect[3]
|
||||
const parentArea = parentRect[2] * parentRect[3]
|
||||
|
||||
if (candidateArea < parentArea) parent = group
|
||||
}
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
return {
|
||||
findParentGroup
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ function widgetWithVueTrack(
|
||||
return { get() {}, set() {} }
|
||||
})
|
||||
}
|
||||
export function useReactiveWidgetValue(widget: IBaseWidget) {
|
||||
function useReactiveWidgetValue(widget: IBaseWidget) {
|
||||
widgetWithVueTrack(widget)
|
||||
widget.vueTrack()
|
||||
return widget.value
|
||||
@@ -120,12 +120,59 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||
*/
|
||||
interface SharedWidgetEnhancements {
|
||||
/** Reactive widget value that updates when the widget changes */
|
||||
value: WidgetValue
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Node type (for subgraph promoted widgets) */
|
||||
nodeType?: string
|
||||
/** Border style for promoted/advanced widgets */
|
||||
borderStyle?: string
|
||||
/** Widget label */
|
||||
label?: string
|
||||
/** Widget options */
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts common widget enhancements shared across different rendering contexts.
|
||||
* This function centralizes the logic for extracting metadata and reactive values
|
||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
||||
*/
|
||||
export function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): SharedWidgetEnhancements {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
value: useReactiveWidgetValue(widget),
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
||||
nodeType: getNodeType(node, widget),
|
||||
borderStyle: widget.promoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined,
|
||||
label: widget.label,
|
||||
options: widget.options
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
@@ -161,16 +208,13 @@ export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
const borderStyle = widget.promoted
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
const value = normalizeWidgetValue(v)
|
||||
widget.value = value ?? undefined
|
||||
@@ -185,16 +229,10 @@ export function safeWidgetMapper(
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: useReactiveWidgetValue(widget),
|
||||
borderStyle,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
controlWidget: getControlWidget(widget),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
nodeType: getNodeType(node, widget),
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user