live selection (#7465)

## Summary

Add real-time selection feedback during marquee drag, matching the
behavior users expect from other applications.

## Changes

- Nodes and groups are now selected/deselected instantly as the
selection rectangle moves
- Supports all modifier keys (Shift to add, Alt to subtract) during drag
- Added Comfy.Graph.LiveSelection setting (off by default)

## Rationale

This interaction pattern is standard across virtually all design and
productivity software:
- Operating Systems: Windows Explorer, macOS Finder, and Linux file
managers all show live selection feedback when dragging
- Design Tools: Figma, Sketch, Adobe Illustrator, Photoshop, and Blender
use real-time selection
- IDEs: VS Code, JetBrains IDEs show live selection in file explorers
- Node Editors: Unreal Engine Blueprints, Unity Shader Graph, and
Houdini all support live selection

## Screenshots

https://github.com/user-attachments/assets/8b0c2217-47f9-4422-9cab-cb39e145310c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7465-live-selection-2c96d73d36508133a4a6f917955d55b3)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-12-15 19:22:39 -05:00
committed by GitHub
parent 18ce8e940a
commit dec929909b
4 changed files with 154 additions and 41 deletions

View File

@@ -707,6 +707,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** The start position of the drag zoom and original read-only state. */
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
/** If true, enable live selection during drag. Nodes are selected/deselected in real-time. */
liveSelection: boolean = false
getMenuOptions?(): IContextMenuValue<string>[]
getExtraMenuOptions?(
canvas: LGraphCanvas,
@@ -2627,7 +2630,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.processSelect(clickedItem, eUp)
}
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
pointer.onDragEnd = (upEvent) => this.#handleMultiSelect(upEvent, dragRect)
if (this.liveSelection) {
const initialSelection = new Set(this.selectedItems)
pointer.onDrag = (eMove) =>
this.handleLiveSelect(eMove, dragRect, initialSelection)
pointer.onDragEnd = () => this.finalizeLiveSelect()
} else {
// Classic mode: select only when drag ends
pointer.onDragEnd = (upEvent) =>
this.#handleMultiSelect(upEvent, dragRect)
}
pointer.finally = () => (this.dragging_rectangle = null)
}
@@ -4087,76 +4103,156 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.setDirty(true)
}
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this
if (!graph) throw new NullGraphError()
/**
* Normalizes a drag rectangle to have positive width and height.
* @param dragRect The drag rectangle to normalize (modified in place)
* @returns The normalized rectangle
*/
#normalizeDragRect(dragRect: Rect): Rect {
const w = Math.abs(dragRect[2])
const h = Math.abs(dragRect[3])
if (dragRect[2] < 0) dragRect[0] -= w
if (dragRect[3] < 0) dragRect[1] -= h
dragRect[2] = w
dragRect[3] = h
return dragRect
}
// Select nodes - any part of the node is in the select area
const isSelected = new Set<Positionable>()
const notSelected: Positionable[] = []
/**
* Gets all positionable items that overlap with the given rectangle.
* @param rect The rectangle to check against
* @returns Set of positionable items that overlap with the rectangle
*/
#getItemsInRect(rect: Rect): Set<Positionable> {
const { graph, subgraph } = this
if (!graph) throw new NullGraphError()
const items = new Set<Positionable>()
if (subgraph) {
const { inputNode, outputNode } = subgraph
if (overlapBounding(dragRect, inputNode.boundingRect)) {
addPositionable(inputNode)
}
if (overlapBounding(dragRect, outputNode.boundingRect)) {
addPositionable(outputNode)
}
if (overlapBounding(rect, inputNode.boundingRect)) items.add(inputNode)
if (overlapBounding(rect, outputNode.boundingRect)) items.add(outputNode)
}
for (const nodeX of graph._nodes) {
if (overlapBounding(dragRect, nodeX.boundingRect)) {
addPositionable(nodeX)
}
for (const node of graph._nodes) {
if (overlapBounding(rect, node.boundingRect)) items.add(node)
}
// Select groups - the group is wholly inside the select area
// Check groups (must be wholly inside)
for (const group of graph.groups) {
if (!containsRect(dragRect, group._bounding)) continue
group.recomputeInsideNodes()
addPositionable(group)
if (containsRect(rect, group._bounding)) {
group.recomputeInsideNodes()
items.add(group)
}
}
// Select reroutes - the centre point is inside the select area
// Check reroutes (center point must be inside)
for (const reroute of graph.reroutes.values()) {
if (!isPointInRect(reroute.pos, dragRect)) continue
selectedItems.add(reroute)
reroute.selected = true
addPositionable(reroute)
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
}
return items
}
/**
* Handles live selection updates during drag. Called on each pointer move.
* @param e The pointer move event
* @param dragRect The current drag rectangle
* @param initialSelection The selection state before the drag started
*/
private handleLiveSelect(
e: CanvasPointerEvent,
dragRect: Rect,
initialSelection: Set<Positionable>
): void {
// Ensure rect is current even if pointer.onDrag fires before processMouseMove updates it
dragRect[2] = e.canvasX - dragRect[0]
dragRect[3] = e.canvasY - dragRect[1]
// Create a normalized copy for overlap checking
const normalizedRect: Rect = [
dragRect[0],
dragRect[1],
dragRect[2],
dragRect[3]
]
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const desired = new Set<Positionable>()
if (e.shiftKey && !e.altKey) {
for (const item of initialSelection) desired.add(item)
for (const item of itemsInRect) desired.add(item)
} else if (e.altKey && !e.shiftKey) {
for (const item of initialSelection)
if (!itemsInRect.has(item)) desired.add(item)
} else {
for (const item of itemsInRect) desired.add(item)
}
let changed = false
for (const item of [...this.selectedItems]) {
if (!desired.has(item)) {
this.deselect(item)
changed = true
}
}
for (const item of desired) {
if (!this.selectedItems.has(item)) {
this.select(item)
changed = true
}
}
if (changed) {
this.onSelectionChange?.(this.selected_nodes)
this.setDirty(true)
}
}
/**
* Finalizes the live selection when drag ends.
*/
private finalizeLiveSelect(): void {
// Selection is already updated by handleLiveSelect
// Just trigger the final selection change callback
this.onSelectionChange?.(this.selected_nodes)
}
/**
* Handles multi-select when drag ends (classic mode).
* @param e The pointer up event
* @param dragRect The drag rectangle
*/
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect): void {
const normalizedRect: Rect = [
dragRect[0],
dragRect[1],
dragRect[2],
dragRect[3]
]
this.#normalizeDragRect(normalizedRect)
const itemsInRect = this.#getItemsInRect(normalizedRect)
const { selectedItems } = this
if (e.shiftKey) {
// Add to selection
for (const item of notSelected) this.select(item)
for (const item of itemsInRect) this.select(item)
} else if (e.altKey) {
// Remove from selection
for (const item of isSelected) this.deselect(item)
for (const item of itemsInRect) this.deselect(item)
} else {
// Replace selection
for (const item of selectedItems.values()) {
if (!isSelected.has(item)) this.deselect(item)
if (!itemsInRect.has(item)) this.deselect(item)
}
for (const item of notSelected) this.select(item)
for (const item of itemsInRect) this.select(item)
}
this.onSelectionChange?.(this.selected_nodes)
function addPositionable(item: Positionable): void {
if (!item.selected || !selectedItems.has(item)) notSelected.push(item)
else isSelected.add(item)
}
this.onSelectionChange?.(this.selected_nodes)
}
/**

View File

@@ -93,6 +93,12 @@ export const useLitegraphSettings = () => {
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
})
watchEffect(() => {
const liveSelection = settingStore.get('Comfy.Graph.LiveSelection')
const { canvas } = canvasStore
if (canvas) canvas.liveSelection = liveSelection
})
watchEffect(() => {
CanvasPointer.doubleClickTime = settingStore.get(
'Comfy.Pointer.DoubleClickTime'

View File

@@ -706,6 +706,16 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.4.0'
},
{
id: 'Comfy.Graph.LiveSelection',
category: ['LiteGraph', 'Canvas', 'LiveSelection'],
name: 'Live selection',
tooltip:
'When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.36.1'
},
{
id: 'Comfy.Pointer.ClickDrift',
category: ['LiteGraph', 'Pointer', 'ClickDrift'],

View File

@@ -400,6 +400,7 @@ const zSettings = z.object({
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
'Comfy.Graph.LiveSelection': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),