mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
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:
@@ -707,6 +707,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
/** The start position of the drag zoom and original read-only state. */
|
/** The start position of the drag zoom and original read-only state. */
|
||||||
#dragZoomStart: { pos: Point; scale: number; readOnly: boolean } | null = null
|
#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>[]
|
getMenuOptions?(): IContextMenuValue<string>[]
|
||||||
getExtraMenuOptions?(
|
getExtraMenuOptions?(
|
||||||
canvas: LGraphCanvas,
|
canvas: LGraphCanvas,
|
||||||
@@ -2627,7 +2630,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
this.processSelect(clickedItem, eUp)
|
this.processSelect(clickedItem, eUp)
|
||||||
}
|
}
|
||||||
pointer.onDragStart = () => (this.dragging_rectangle = dragRect)
|
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)
|
pointer.finally = () => (this.dragging_rectangle = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4087,76 +4103,156 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
this.setDirty(true)
|
this.setDirty(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
|
/**
|
||||||
// Process drag
|
* Normalizes a drag rectangle to have positive width and height.
|
||||||
// Convert Point pair (pos, offset) to Rect
|
* @param dragRect The drag rectangle to normalize (modified in place)
|
||||||
const { graph, selectedItems, subgraph } = this
|
* @returns The normalized rectangle
|
||||||
if (!graph) throw new NullGraphError()
|
*/
|
||||||
|
#normalizeDragRect(dragRect: Rect): Rect {
|
||||||
const w = Math.abs(dragRect[2])
|
const w = Math.abs(dragRect[2])
|
||||||
const h = Math.abs(dragRect[3])
|
const h = Math.abs(dragRect[3])
|
||||||
if (dragRect[2] < 0) dragRect[0] -= w
|
if (dragRect[2] < 0) dragRect[0] -= w
|
||||||
if (dragRect[3] < 0) dragRect[1] -= h
|
if (dragRect[3] < 0) dragRect[1] -= h
|
||||||
dragRect[2] = w
|
dragRect[2] = w
|
||||||
dragRect[3] = h
|
dragRect[3] = h
|
||||||
|
return dragRect
|
||||||
|
}
|
||||||
|
|
||||||
// Select nodes - any part of the node is in the select area
|
/**
|
||||||
const isSelected = new Set<Positionable>()
|
* Gets all positionable items that overlap with the given rectangle.
|
||||||
const notSelected: Positionable[] = []
|
* @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) {
|
if (subgraph) {
|
||||||
const { inputNode, outputNode } = subgraph
|
const { inputNode, outputNode } = subgraph
|
||||||
|
if (overlapBounding(rect, inputNode.boundingRect)) items.add(inputNode)
|
||||||
if (overlapBounding(dragRect, inputNode.boundingRect)) {
|
if (overlapBounding(rect, outputNode.boundingRect)) items.add(outputNode)
|
||||||
addPositionable(inputNode)
|
|
||||||
}
|
|
||||||
if (overlapBounding(dragRect, outputNode.boundingRect)) {
|
|
||||||
addPositionable(outputNode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const nodeX of graph._nodes) {
|
for (const node of graph._nodes) {
|
||||||
if (overlapBounding(dragRect, nodeX.boundingRect)) {
|
if (overlapBounding(rect, node.boundingRect)) items.add(node)
|
||||||
addPositionable(nodeX)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select groups - the group is wholly inside the select area
|
// Check groups (must be wholly inside)
|
||||||
for (const group of graph.groups) {
|
for (const group of graph.groups) {
|
||||||
if (!containsRect(dragRect, group._bounding)) continue
|
if (containsRect(rect, group._bounding)) {
|
||||||
|
group.recomputeInsideNodes()
|
||||||
group.recomputeInsideNodes()
|
items.add(group)
|
||||||
addPositionable(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()) {
|
for (const reroute of graph.reroutes.values()) {
|
||||||
if (!isPointInRect(reroute.pos, dragRect)) continue
|
if (isPointInRect(reroute.pos, rect)) items.add(reroute)
|
||||||
|
|
||||||
selectedItems.add(reroute)
|
|
||||||
reroute.selected = true
|
|
||||||
addPositionable(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) {
|
if (e.shiftKey) {
|
||||||
// Add to selection
|
// Add to selection
|
||||||
for (const item of notSelected) this.select(item)
|
for (const item of itemsInRect) this.select(item)
|
||||||
} else if (e.altKey) {
|
} else if (e.altKey) {
|
||||||
// Remove from selection
|
// Remove from selection
|
||||||
for (const item of isSelected) this.deselect(item)
|
for (const item of itemsInRect) this.deselect(item)
|
||||||
} else {
|
} else {
|
||||||
// Replace selection
|
// Replace selection
|
||||||
for (const item of selectedItems.values()) {
|
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 {
|
this.onSelectionChange?.(this.selected_nodes)
|
||||||
if (!item.selected || !selectedItems.has(item)) notSelected.push(item)
|
|
||||||
else isSelected.add(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export const useLitegraphSettings = () => {
|
|||||||
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
|
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const liveSelection = settingStore.get('Comfy.Graph.LiveSelection')
|
||||||
|
const { canvas } = canvasStore
|
||||||
|
if (canvas) canvas.liveSelection = liveSelection
|
||||||
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
CanvasPointer.doubleClickTime = settingStore.get(
|
CanvasPointer.doubleClickTime = settingStore.get(
|
||||||
'Comfy.Pointer.DoubleClickTime'
|
'Comfy.Pointer.DoubleClickTime'
|
||||||
|
|||||||
@@ -706,6 +706,16 @@ export const CORE_SETTINGS: SettingParams[] = [
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
versionAdded: '1.4.0'
|
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',
|
id: 'Comfy.Pointer.ClickDrift',
|
||||||
category: ['LiteGraph', 'Pointer', 'ClickDrift'],
|
category: ['LiteGraph', 'Pointer', 'ClickDrift'],
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ const zSettings = z.object({
|
|||||||
'Comfy.Graph.CanvasInfo': z.boolean(),
|
'Comfy.Graph.CanvasInfo': z.boolean(),
|
||||||
'Comfy.Graph.CanvasMenu': z.boolean(),
|
'Comfy.Graph.CanvasMenu': z.boolean(),
|
||||||
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
|
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
|
||||||
|
'Comfy.Graph.LiveSelection': z.boolean(),
|
||||||
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
|
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
|
||||||
'Comfy.Graph.ZoomSpeed': z.number(),
|
'Comfy.Graph.ZoomSpeed': z.number(),
|
||||||
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
|
'Comfy.Group.DoubleClickTitleToEdit': z.boolean(),
|
||||||
|
|||||||
Reference in New Issue
Block a user