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:
Christian Byrne
2026-03-13 06:13:18 -07:00
committed by GitHub
parent f4bf169b2f
commit e119383072
6 changed files with 574 additions and 3 deletions

View 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)
})
})
})

View File

@@ -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

View File

@@ -162,4 +162,12 @@ export const useLitegraphSettings = () => {
'Comfy.EnableWorkflowViewRestore'
)
})
watchEffect(() => {
const selectChildren = settingStore.get(
'LiteGraph.Group.SelectChildrenOnClick'
)
if (canvasStore.canvas)
canvasStore.canvas.groupSelectChildren = selectChildren
})
}

View File

@@ -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'
}
]

View File

@@ -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>