From dec929909bbe84c35df882949c389ef0bc9aa8cc Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Mon, 15 Dec 2025 19:22:39 -0500 Subject: [PATCH] live selection (#7465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/lib/litegraph/src/LGraphCanvas.ts | 178 ++++++++++++++---- .../composables/useLitegraphSettings.ts | 6 + .../settings/constants/coreSettings.ts | 10 + src/schemas/apiSchema.ts | 1 + 4 files changed, 154 insertions(+), 41 deletions(-) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 64c78797d..7792260a4 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -707,6 +707,9 @@ export class LGraphCanvas implements CustomEventDispatcher /** 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[] getExtraMenuOptions?( canvas: LGraphCanvas, @@ -2627,7 +2630,20 @@ export class LGraphCanvas implements CustomEventDispatcher 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 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() - 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 { + const { graph, subgraph } = this + if (!graph) throw new NullGraphError() + + const items = new Set() 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 + ): 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() + 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) } /** diff --git a/src/platform/settings/composables/useLitegraphSettings.ts b/src/platform/settings/composables/useLitegraphSettings.ts index 468c7d339..ffdb58c92 100644 --- a/src/platform/settings/composables/useLitegraphSettings.ts +++ b/src/platform/settings/composables/useLitegraphSettings.ts @@ -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' diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 3033cdf59..85032dcdf 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -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'], diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index b26d3b6aa..dd8dc6cd4 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -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(),