mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] sync subgraph node titles with breadcrumb renaming (#4565)
This commit is contained in:
@@ -42,6 +42,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const MIN_WIDTH = 28
|
||||
const ITEM_GAP = 8
|
||||
@@ -71,6 +72,14 @@ const items = computed(() => {
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
},
|
||||
updateTitle: (title: string) => {
|
||||
const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph
|
||||
if (!rootGraph) return
|
||||
|
||||
forEachSubgraphNode(rootGraph, subgraph.id, (node) => {
|
||||
node.title = title
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ const rename = async (
|
||||
initialName: string
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
} else if (workflowStore.activeWorkflow) {
|
||||
|
||||
@@ -86,13 +86,7 @@ export function triggerCallbackOnAllNodes(
|
||||
graph: LGraph | Subgraph,
|
||||
callbackProperty: keyof LGraphNode
|
||||
): void {
|
||||
visitGraphNodes(graph, (node) => {
|
||||
// Recursively process subgraphs first
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
triggerCallbackOnAllNodes(node.subgraph, callbackProperty)
|
||||
}
|
||||
|
||||
// Invoke callback if it exists on the node
|
||||
forEachNode(graph, (node) => {
|
||||
const callback = node[callbackProperty]
|
||||
if (typeof callback === 'function') {
|
||||
callback.call(node)
|
||||
@@ -100,6 +94,58 @@ export function triggerCallbackOnAllNodes(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a function over all nodes in a graph hierarchy (including subgraphs).
|
||||
* This is a pure functional traversal that doesn't mutate the graph.
|
||||
*
|
||||
* @param graph - The root graph to traverse
|
||||
* @param mapFn - Function to apply to each node
|
||||
* @returns Array of mapped results (excluding undefined values)
|
||||
*/
|
||||
export function mapAllNodes<T>(
|
||||
graph: LGraph | Subgraph,
|
||||
mapFn: (node: LGraphNode) => T | undefined
|
||||
): T[] {
|
||||
const results: T[] = []
|
||||
|
||||
visitGraphNodes(graph, (node) => {
|
||||
// Recursively map over subgraphs first
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
results.push(...mapAllNodes(node.subgraph, mapFn))
|
||||
}
|
||||
|
||||
// Apply map function to current node
|
||||
const result = mapFn(node)
|
||||
if (result !== undefined) {
|
||||
results.push(result)
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a side-effect function on all nodes in a graph hierarchy.
|
||||
* This is for operations that modify nodes or perform side effects.
|
||||
*
|
||||
* @param graph - The root graph to traverse
|
||||
* @param fn - Function to execute on each node
|
||||
*/
|
||||
export function forEachNode(
|
||||
graph: LGraph | Subgraph,
|
||||
fn: (node: LGraphNode) => void
|
||||
): void {
|
||||
visitGraphNodes(graph, (node) => {
|
||||
// Recursively process subgraphs first
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
forEachNode(node.subgraph, fn)
|
||||
}
|
||||
|
||||
// Execute function on current node
|
||||
fn(node)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all nodes in a graph hierarchy (including subgraphs) into a flat array.
|
||||
*
|
||||
@@ -111,21 +157,12 @@ export function collectAllNodes(
|
||||
graph: LGraph | Subgraph,
|
||||
filter?: (node: LGraphNode) => boolean
|
||||
): LGraphNode[] {
|
||||
const nodes: LGraphNode[] = []
|
||||
|
||||
visitGraphNodes(graph, (node) => {
|
||||
// Recursively collect from subgraphs
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
nodes.push(...collectAllNodes(node.subgraph, filter))
|
||||
}
|
||||
|
||||
// Add node if it passes the filter (or no filter provided)
|
||||
return mapAllNodes(graph, (node) => {
|
||||
if (!filter || filter(node)) {
|
||||
nodes.push(node)
|
||||
return node
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,3 +278,63 @@ export function getNodeByLocatorId(
|
||||
|
||||
return targetSubgraph.getNodeById(localNodeId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the root graph from any graph in the hierarchy.
|
||||
*
|
||||
* @param graph - Any graph or subgraph in the hierarchy
|
||||
* @returns The root graph
|
||||
*/
|
||||
export function getRootGraph(graph: LGraph | Subgraph): LGraph | Subgraph {
|
||||
let current: LGraph | Subgraph = graph
|
||||
while ('rootGraph' in current && current.rootGraph) {
|
||||
current = current.rootGraph
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a function to all nodes whose type matches a subgraph ID.
|
||||
* Operates on the entire graph hierarchy starting from the root.
|
||||
*
|
||||
* @param rootGraph - The root graph to search in
|
||||
* @param subgraphId - The ID/type of the subgraph to match nodes against
|
||||
* @param fn - Function to apply to each matching node
|
||||
*/
|
||||
export function forEachSubgraphNode(
|
||||
rootGraph: LGraph | Subgraph | null | undefined,
|
||||
subgraphId: string | null | undefined,
|
||||
fn: (node: LGraphNode) => void
|
||||
): void {
|
||||
if (!rootGraph || !subgraphId) return
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
if (node.type === subgraphId) {
|
||||
fn(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a function over all nodes whose type matches a subgraph ID.
|
||||
* Operates on the entire graph hierarchy starting from the root.
|
||||
*
|
||||
* @param rootGraph - The root graph to search in
|
||||
* @param subgraphId - The ID/type of the subgraph to match nodes against
|
||||
* @param mapFn - Function to apply to each matching node
|
||||
* @returns Array of mapped results
|
||||
*/
|
||||
export function mapSubgraphNodes<T>(
|
||||
rootGraph: LGraph | Subgraph | null | undefined,
|
||||
subgraphId: string | null | undefined,
|
||||
mapFn: (node: LGraphNode) => T
|
||||
): T[] {
|
||||
if (!rootGraph || !subgraphId) return []
|
||||
|
||||
return mapAllNodes(rootGraph, (node) => {
|
||||
if (node.type === subgraphId) {
|
||||
return mapFn(node)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ import {
|
||||
collectAllNodes,
|
||||
findNodeInHierarchy,
|
||||
findSubgraphByUuid,
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getLocalNodeIdFromExecutionId,
|
||||
getNodeByExecutionId,
|
||||
getNodeByLocatorId,
|
||||
getRootGraph,
|
||||
getSubgraphPathFromExecutionId,
|
||||
mapAllNodes,
|
||||
mapSubgraphNodes,
|
||||
parseExecutionId,
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
@@ -283,6 +288,141 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAllNodes', () => {
|
||||
it('should map over all nodes in a flat graph', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const results = mapAllNodes(graph, (node) => node.id)
|
||||
|
||||
expect(results).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should map over nodes in subgraphs', () => {
|
||||
const subNode = createMockNode(100)
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
createMockNode(2, { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const results = mapAllNodes(graph, (node) => node.id)
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results).toContain(100)
|
||||
})
|
||||
|
||||
it('should exclude undefined results', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const results = mapAllNodes(graph, (node) => {
|
||||
return Number(node.id) > 1 ? node.id : undefined
|
||||
})
|
||||
|
||||
expect(results).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
const deepNode = createMockNode(300)
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode = createMockNode(200)
|
||||
const midSubgraphNode = createMockNode(201, {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [
|
||||
midNode,
|
||||
midSubgraphNode
|
||||
])
|
||||
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
createMockNode(2, { isSubgraph: true, subgraph: midSubgraph })
|
||||
]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const results = mapAllNodes(graph, (node) => `node-${node.id}`)
|
||||
|
||||
expect(results).toHaveLength(5)
|
||||
expect(results).toContain('node-300')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forEachNode', () => {
|
||||
it('should execute function on all nodes in a flat graph', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const visited: number[] = []
|
||||
forEachNode(graph, (node) => {
|
||||
visited.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(visited).toHaveLength(3)
|
||||
expect(visited).toContain(1)
|
||||
expect(visited).toContain(2)
|
||||
expect(visited).toContain(3)
|
||||
})
|
||||
|
||||
it('should execute function on nodes in subgraphs', () => {
|
||||
const subNode = createMockNode(100)
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
createMockNode(2, { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const visited: number[] = []
|
||||
forEachNode(graph, (node) => {
|
||||
visited.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(visited).toHaveLength(3)
|
||||
expect(visited).toContain(100)
|
||||
})
|
||||
|
||||
it('should allow node mutations', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
// Add a title property to each node
|
||||
forEachNode(graph, (node) => {
|
||||
;(node as any).title = `Node ${node.id}`
|
||||
})
|
||||
|
||||
expect(nodes[0]).toHaveProperty('title', 'Node 1')
|
||||
expect(nodes[1]).toHaveProperty('title', 'Node 2')
|
||||
expect(nodes[2]).toHaveProperty('title', 'Node 3')
|
||||
})
|
||||
|
||||
it('should handle node type matching for subgraph references', () => {
|
||||
const subgraphId = 'my-subgraph-123'
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
|
||||
createMockNode(3),
|
||||
{ ...createMockNode(4), type: subgraphId } as LGraphNode
|
||||
]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const matchingNodes: number[] = []
|
||||
forEachNode(graph, (node) => {
|
||||
if (node.type === subgraphId) {
|
||||
matchingNodes.push(node.id as number)
|
||||
}
|
||||
})
|
||||
|
||||
expect(matchingNodes).toEqual([2, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNodeInHierarchy', () => {
|
||||
it('should find node in root graph', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
@@ -482,5 +622,140 @@ describe('graphTraversalUtil', () => {
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRootGraph', () => {
|
||||
it('should return the same graph if it is already root', () => {
|
||||
const graph = createMockGraph([])
|
||||
expect(getRootGraph(graph)).toBe(graph)
|
||||
})
|
||||
|
||||
it('should return root graph from subgraph', () => {
|
||||
const rootGraph = createMockGraph([])
|
||||
const subgraph = createMockSubgraph('sub-uuid', [])
|
||||
;(subgraph as any).rootGraph = rootGraph
|
||||
|
||||
expect(getRootGraph(subgraph)).toBe(rootGraph)
|
||||
})
|
||||
|
||||
it('should return root graph from deeply nested subgraph', () => {
|
||||
const rootGraph = createMockGraph([])
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [])
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [])
|
||||
|
||||
;(midSubgraph as any).rootGraph = rootGraph
|
||||
;(deepSubgraph as any).rootGraph = midSubgraph
|
||||
|
||||
expect(getRootGraph(deepSubgraph)).toBe(rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forEachSubgraphNode', () => {
|
||||
it('should apply function to all nodes matching subgraph type', () => {
|
||||
const subgraphId = 'my-subgraph-123'
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
|
||||
createMockNode(3),
|
||||
{ ...createMockNode(4), type: subgraphId } as LGraphNode
|
||||
]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const matchingIds: number[] = []
|
||||
forEachSubgraphNode(graph, subgraphId, (node) => {
|
||||
matchingIds.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(matchingIds).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should work with root graph directly', () => {
|
||||
const subgraphId = 'target-subgraph'
|
||||
const rootNodes = [
|
||||
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
|
||||
createMockNode(2),
|
||||
{ ...createMockNode(3), type: subgraphId } as LGraphNode
|
||||
]
|
||||
const rootGraph = createMockGraph(rootNodes)
|
||||
|
||||
const matchingIds: number[] = []
|
||||
forEachSubgraphNode(rootGraph, subgraphId, (node) => {
|
||||
matchingIds.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(matchingIds).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('should handle null inputs gracefully', () => {
|
||||
const fn = vi.fn()
|
||||
|
||||
forEachSubgraphNode(null, 'id', fn)
|
||||
forEachSubgraphNode(createMockGraph([]), null, fn)
|
||||
forEachSubgraphNode(null, null, fn)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow node mutations like title updates', () => {
|
||||
const subgraphId = 'my-subgraph'
|
||||
const nodes = [
|
||||
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
|
||||
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
|
||||
createMockNode(3)
|
||||
]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
forEachSubgraphNode(graph, subgraphId, (node) => {
|
||||
;(node as any).title = 'Updated Title'
|
||||
})
|
||||
|
||||
expect(nodes[0]).toHaveProperty('title', 'Updated Title')
|
||||
expect(nodes[1]).toHaveProperty('title', 'Updated Title')
|
||||
expect(nodes[2]).not.toHaveProperty('title', 'Updated Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSubgraphNodes', () => {
|
||||
it('should map over nodes matching subgraph type', () => {
|
||||
const subgraphId = 'my-subgraph-123'
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
|
||||
createMockNode(3),
|
||||
{ ...createMockNode(4), type: subgraphId } as LGraphNode
|
||||
]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const results = mapSubgraphNodes(graph, subgraphId, (node) => node.id)
|
||||
|
||||
expect(results).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should return empty array for null inputs', () => {
|
||||
expect(mapSubgraphNodes(null, 'id', (n) => n.id)).toEqual([])
|
||||
expect(
|
||||
mapSubgraphNodes(createMockGraph([]), null, (n) => n.id)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('should work with complex transformations', () => {
|
||||
const subgraphId = 'target'
|
||||
const nodes = [
|
||||
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
|
||||
{ ...createMockNode(2), type: 'other' } as LGraphNode,
|
||||
{ ...createMockNode(3), type: subgraphId } as LGraphNode
|
||||
]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const results = mapSubgraphNodes(graph, subgraphId, (node) => ({
|
||||
id: node.id,
|
||||
isTarget: true
|
||||
}))
|
||||
|
||||
expect(results).toEqual([
|
||||
{ id: 1, isTarget: true },
|
||||
{ id: 3, isTarget: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user