mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 09:27:41 +00:00
feat: select group children on click (#9149)
## Summary Add a setting to select all children (nodes, reroutes, nested groups) when clicking a group on the canvas. ## Changes - **What**: New `LiteGraph.Group.SelectChildrenOnClick` boolean setting (default: `false`). When enabled, selecting a group cascades `select()` to all its `_children`, and deselecting cascades `deselect()`. Recursion handles nested groups naturally. No double-move risk — the drag handler already uses `skipChildren=true`. The setting is wired via `onChange` to `canvas.groupSelectChildren`, keeping litegraph free of platform imports. ## Review Focus - The select/deselect cascading in `LGraphCanvas.select()` / `deselect()` — verify no infinite recursion risk with deeply nested groups. - The `groupSelectChildren` property is set via the setting's `onChange` callback on `LGraphCanvas.active_canvas` — confirm this covers canvas re-creation scenarios. ## Screenshots (if applicable) N/A — behavioral change behind a setting toggle. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9149-feat-select-group-children-on-click-3116d73d365081a1a7b8c82dea95b242) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
363
src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts
Normal file
363
src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} satisfies Partial<CanvasRenderingContext2D>
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('test')
|
||||
}
|
||||
}
|
||||
|
||||
describe('LGraphCanvas group selection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let group: LGraphGroup
|
||||
let nodeA: TestNode
|
||||
let nodeB: TestNode
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
|
||||
group = new LGraphGroup('TestGroup')
|
||||
group._bounding.set([0, 0, 500, 500])
|
||||
graph.add(group)
|
||||
|
||||
nodeA = new TestNode()
|
||||
nodeA.pos = [50, 50]
|
||||
graph.add(nodeA)
|
||||
|
||||
nodeB = new TestNode()
|
||||
nodeB.pos = [100, 100]
|
||||
graph.add(nodeB)
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
})
|
||||
|
||||
describe('select with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('selects all children when selecting a group', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(group)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeB)).toBe(true)
|
||||
})
|
||||
|
||||
it('recursively selects nested group children', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
expect(innerGroup.selected).toBe(true)
|
||||
expect(innerNode.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerGroup)).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('selects descendants of already-selected nested groups', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
// Pre-select the inner group before selecting the outer group
|
||||
canvas.select(innerGroup)
|
||||
expect(innerGroup.selected).toBe(true)
|
||||
expect(innerNode.selected).toBeFalsy()
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
expect(innerNode.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(innerNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles deeply nested groups (depth 5)', () => {
|
||||
const groups: LGraphGroup[] = [group]
|
||||
const nodes: TestNode[] = [nodeA, nodeB]
|
||||
|
||||
for (let depth = 1; depth <= 5; depth++) {
|
||||
const offset = depth * 10
|
||||
const size = 500 - depth * 20
|
||||
|
||||
const nestedGroup = new LGraphGroup(`Depth${depth}`)
|
||||
nestedGroup._bounding.set([offset, offset, size, size])
|
||||
graph.add(nestedGroup)
|
||||
groups.push(nestedGroup)
|
||||
|
||||
const nestedNode = new TestNode()
|
||||
nestedNode.pos = [offset + 5, offset + 5]
|
||||
graph.add(nestedNode)
|
||||
nodes.push(nestedNode)
|
||||
}
|
||||
|
||||
// Recompute from innermost to outermost
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
groups[i].recomputeInsideNodes()
|
||||
}
|
||||
|
||||
canvas.select(group)
|
||||
|
||||
for (const g of groups) {
|
||||
expect(g.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(g)).toBe(true)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
expect(n.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('select with groupSelectChildren disabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = false
|
||||
})
|
||||
|
||||
it('does not select children when selecting a group', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBeFalsy()
|
||||
expect(nodeB.selected).toBeFalsy()
|
||||
expect(canvas.selectedItems.has(group)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deselect with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('deselects all children when deselecting a group', () => {
|
||||
canvas.select(group)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(false)
|
||||
expect(nodeB.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(group)).toBe(false)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(false)
|
||||
})
|
||||
|
||||
it('recursively deselects nested group children', () => {
|
||||
const innerGroup = new LGraphGroup('InnerGroup')
|
||||
innerGroup._bounding.set([40, 40, 200, 200])
|
||||
graph.add(innerGroup)
|
||||
|
||||
const innerNode = new TestNode()
|
||||
innerNode.pos = [60, 60]
|
||||
graph.add(innerNode)
|
||||
|
||||
innerGroup.recomputeInsideNodes()
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
canvas.select(group)
|
||||
expect(innerNode.selected).toBe(true)
|
||||
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(innerGroup.selected).toBe(false)
|
||||
expect(innerNode.selected).toBe(false)
|
||||
})
|
||||
|
||||
it('handles deeply nested deselection (depth 5)', () => {
|
||||
const groups: LGraphGroup[] = [group]
|
||||
const nodes: TestNode[] = [nodeA, nodeB]
|
||||
|
||||
for (let depth = 1; depth <= 5; depth++) {
|
||||
const offset = depth * 10
|
||||
const size = 500 - depth * 20
|
||||
|
||||
const nestedGroup = new LGraphGroup(`Depth${depth}`)
|
||||
nestedGroup._bounding.set([offset, offset, size, size])
|
||||
graph.add(nestedGroup)
|
||||
groups.push(nestedGroup)
|
||||
|
||||
const nestedNode = new TestNode()
|
||||
nestedNode.pos = [offset + 5, offset + 5]
|
||||
graph.add(nestedNode)
|
||||
nodes.push(nestedNode)
|
||||
}
|
||||
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
groups[i].recomputeInsideNodes()
|
||||
}
|
||||
|
||||
canvas.select(group)
|
||||
canvas.deselect(group)
|
||||
|
||||
for (const g of groups) {
|
||||
expect(g.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(g)).toBe(false)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
expect(n.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(n)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('processSelect modifier-click deselect', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
})
|
||||
|
||||
it('modifier-click deselects only the group, not its children', () => {
|
||||
canvas.select(group)
|
||||
expect(group.selected).toBe(true)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
|
||||
const shiftEvent = { shiftKey: true } as CanvasPointerEvent
|
||||
canvas.processSelect(group, shiftEvent)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(canvas.selectedItems.has(group)).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeA)).toBe(true)
|
||||
expect(canvas.selectedItems.has(nodeB)).toBe(true)
|
||||
})
|
||||
|
||||
it('ctrl-click deselects only the group, not its children', () => {
|
||||
canvas.select(group)
|
||||
|
||||
const ctrlEvent = { ctrlKey: true } as CanvasPointerEvent
|
||||
canvas.processSelect(group, ctrlEvent)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deselect with groupSelectChildren disabled', () => {
|
||||
it('does not deselect children when deselecting a group', () => {
|
||||
canvas.groupSelectChildren = true
|
||||
canvas.select(group)
|
||||
|
||||
canvas.groupSelectChildren = false
|
||||
canvas.deselect(group)
|
||||
|
||||
expect(group.selected).toBe(false)
|
||||
expect(nodeA.selected).toBe(true)
|
||||
expect(nodeB.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelected with groupSelectChildren enabled', () => {
|
||||
beforeEach(() => {
|
||||
canvas.groupSelectChildren = true
|
||||
// Attach canvas to DOM so checkPanels() can query parentNode
|
||||
document.body.appendChild(canvas.canvas)
|
||||
})
|
||||
|
||||
it('deletes group and all selected children', () => {
|
||||
canvas.select(group)
|
||||
|
||||
expect(canvas.selectedItems.size).toBeGreaterThan(1)
|
||||
|
||||
canvas.deleteSelected()
|
||||
|
||||
expect(graph.nodes).not.toContain(nodeA)
|
||||
expect(graph.nodes).not.toContain(nodeB)
|
||||
expect(graph.groups).not.toContain(group)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -565,6 +565,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
allow_dragnodes: boolean
|
||||
allow_interaction: boolean
|
||||
multi_select: boolean
|
||||
groupSelectChildren: boolean
|
||||
allow_searchbox: boolean
|
||||
allow_reconnect_links: boolean
|
||||
align_to_grid: boolean
|
||||
@@ -933,6 +934,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.allow_interaction = true
|
||||
// allow selecting multi nodes without pressing extra keys
|
||||
this.multi_select = false
|
||||
this.groupSelectChildren = false
|
||||
this.allow_searchbox = true
|
||||
// allows to change a connection with having to redo it again
|
||||
this.allow_reconnect_links = true
|
||||
@@ -4371,7 +4373,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!modifySelection) this.deselectAll(item)
|
||||
this.select(item)
|
||||
} else if (modifySelection && !sticky) {
|
||||
this.deselect(item)
|
||||
// Modifier-click toggles only the clicked item, not its children.
|
||||
// Cascade on select is a convenience; cascade on deselect would
|
||||
// remove the user's ability to keep children selected (e.g. for
|
||||
// deletion) after toggling the group off.
|
||||
if (item instanceof LGraphGroup && this.groupSelectChildren) {
|
||||
item.selected = false
|
||||
this.selectedItems.delete(item)
|
||||
this.state.selectionChanged = true
|
||||
} else {
|
||||
this.deselect(item)
|
||||
}
|
||||
} else if (!sticky) {
|
||||
this.deselectAll(item)
|
||||
} else {
|
||||
@@ -4396,6 +4408,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (item instanceof LGraphGroup) {
|
||||
item.recomputeInsideNodes()
|
||||
if (this.groupSelectChildren) {
|
||||
this.#traverseGroupChildren(
|
||||
item,
|
||||
(child) => {
|
||||
if (!child.selected || !this.selectedItems.has(child)) {
|
||||
child.selected = true
|
||||
this.selectedItems.add(child)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
},
|
||||
(child) => this.select(child)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4434,6 +4459,22 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
item.selected = false
|
||||
this.selectedItems.delete(item)
|
||||
this.state.selectionChanged = true
|
||||
|
||||
if (item instanceof LGraphGroup && this.groupSelectChildren) {
|
||||
this.#traverseGroupChildren(
|
||||
item,
|
||||
(child) => {
|
||||
if (child.selected || this.selectedItems.has(child)) {
|
||||
child.selected = false
|
||||
this.selectedItems.delete(child)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
},
|
||||
(child) => this.deselect(child)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!(item instanceof LGraphNode)) return
|
||||
|
||||
// Node-specific handling
|
||||
@@ -4469,6 +4510,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative traversal of a group's descendants.
|
||||
* Calls {@link groupAction} on nested groups and {@link leafAction} on
|
||||
* non-group children. Always recurses into nested groups regardless of
|
||||
* their current selection state.
|
||||
*/
|
||||
#traverseGroupChildren(
|
||||
group: LGraphGroup,
|
||||
groupAction: (child: LGraphGroup) => void,
|
||||
leafAction: (child: Positionable) => void
|
||||
): void {
|
||||
const stack: Positionable[] = [...group._children]
|
||||
while (stack.length > 0) {
|
||||
const child = stack.pop()!
|
||||
if (child instanceof LGraphGroup) {
|
||||
groupAction(child)
|
||||
for (const nested of child._children) stack.push(nested)
|
||||
} else {
|
||||
leafAction(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated See {@link LGraphCanvas.processSelect} */
|
||||
processNodeSelected(item: LGraphNode, e: CanvasPointerEvent): void {
|
||||
this.processSelect(
|
||||
@@ -4601,7 +4665,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.emitBeforeChange()
|
||||
graph.beforeChange()
|
||||
|
||||
for (const item of this.selectedItems) {
|
||||
// Snapshot to prevent mutation during iteration (e.g. group deselect cascade)
|
||||
const toDelete = [...this.selectedItems]
|
||||
for (const item of toDelete) {
|
||||
if (item instanceof LGraphNode) {
|
||||
const node = item
|
||||
if (node.block_delete) continue
|
||||
|
||||
@@ -162,4 +162,12 @@ export const useLitegraphSettings = () => {
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const selectChildren = settingStore.get(
|
||||
'LiteGraph.Group.SelectChildrenOnClick'
|
||||
)
|
||||
if (canvasStore.canvas)
|
||||
canvasStore.canvas.groupSelectChildren = selectChildren
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1258,5 +1258,15 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
},
|
||||
{
|
||||
id: 'LiteGraph.Group.SelectChildrenOnClick',
|
||||
category: ['LiteGraph', 'Group', 'SelectChildrenOnClick'],
|
||||
name: 'Select group children on click',
|
||||
tooltip:
|
||||
'When enabled, clicking a group selects all nodes and items inside it',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.42.0'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -464,7 +464,8 @@ const zSettings = z.object({
|
||||
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean(),
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
|
||||
'LiteGraph.Group.SelectChildrenOnClick': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
|
||||
Reference in New Issue
Block a user