From 0e3314bbd383bd4a8b0d849cdf5da93b49eb68cc Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:42:38 -0800 Subject: [PATCH] Node ghost mode when adding nodes (#8694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds option for adding a node as a "ghost" that follows the cursor until the user left clicks to confirm, or esc/right click to cancel. ## Changes - **What**: Adds option for `ghost` when calling `graph.add` This adds the node with a `flag` of ghost which causes it to render transparent Selects the node, then sets the canvas as dragging to stick the node to the cursor ## Screenshots (if applicable) https://github.com/user-attachments/assets/dcb5702f-aba3-4983-aa40-c51f24a4767a ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8694-Node-ghost-mode-when-adding-nodes-2ff6d73d3650815591f2c28415050463) by [Unito](https://www.unito.io) --- .../tests/nodeGhostPlacement.spec.ts | 162 ++++++++++++++++++ src/components/graph/NodeTooltip.vue | 2 +- src/composables/graph/useGraphNodeManager.ts | 10 ++ src/lib/litegraph/src/LGraph.ts | 46 ++++- src/lib/litegraph/src/LGraphCanvas.ts | 96 ++++++++++- src/lib/litegraph/src/interfaces.ts | 2 + .../vueNodes/components/LGraphNode.vue | 4 +- 7 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 browser_tests/tests/nodeGhostPlacement.spec.ts diff --git a/browser_tests/tests/nodeGhostPlacement.spec.ts b/browser_tests/tests/nodeGhostPlacement.spec.ts new file mode 100644 index 000000000..bac11bc6c --- /dev/null +++ b/browser_tests/tests/nodeGhostPlacement.spec.ts @@ -0,0 +1,162 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../fixtures/ComfyPage' + +type ComfyPage = Parameters[2]>[0]['comfyPage'] + +async function setVueMode(comfyPage: ComfyPage, enabled: boolean) { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled) + if (enabled) { + await comfyPage.vueNodes.waitForNodes() + } +} + +async function addGhostAtCenter(comfyPage: ComfyPage) { + await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + + const viewport = comfyPage.page.viewportSize()! + const centerX = Math.round(viewport.width / 2) + const centerY = Math.round(viewport.height / 2) + + await comfyPage.page.mouse.move(centerX, centerY) + await comfyPage.nextFrame() + + const nodeId = await comfyPage.page.evaluate( + ([clientX, clientY]) => { + const node = window.LiteGraph!.createNode('VAEDecode')! + const event = new MouseEvent('click', { clientX, clientY }) + window.app!.graph.add(node, { ghost: true, dragEvent: event }) + return node.id + }, + [centerX, centerY] as const + ) + await comfyPage.nextFrame() + + return { nodeId, centerX, centerY } +} + +function getNodeById(comfyPage: ComfyPage, nodeId: number | string) { + return comfyPage.page.evaluate((id) => { + const node = window.app!.graph.getNodeById(id) + if (!node) return null + return { ghost: !!node.flags.ghost } + }, nodeId) +} + +for (const mode of ['litegraph', 'vue'] as const) { + test.describe(`Ghost node placement (${mode} mode)`, () => { + test.beforeEach(async ({ comfyPage }) => { + await setVueMode(comfyPage, mode === 'vue') + }) + + test('positions ghost node at cursor', async ({ comfyPage }) => { + await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + + const viewport = comfyPage.page.viewportSize()! + const centerX = Math.round(viewport.width / 2) + const centerY = Math.round(viewport.height / 2) + + await comfyPage.page.mouse.move(centerX, centerY) + await comfyPage.nextFrame() + + const result = await comfyPage.page.evaluate( + ([clientX, clientY]) => { + const node = window.LiteGraph!.createNode('VAEDecode')! + const event = new MouseEvent('click', { clientX, clientY }) + window.app!.graph.add(node, { ghost: true, dragEvent: event }) + + const canvas = window.app!.canvas + const rect = canvas.canvas.getBoundingClientRect() + const cursorCanvasX = + (clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0] + const cursorCanvasY = + (clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1] + + return { + diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX, + diffY: node.pos[1] - 10 - cursorCanvasY + } + }, + [centerX, centerY] as const + ) + await comfyPage.nextFrame() + + expect(Math.abs(result.diffX)).toBeLessThan(5) + expect(Math.abs(result.diffY)).toBeLessThan(5) + }) + + test('left-click confirms ghost placement', async ({ comfyPage }) => { + const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage) + + const before = await getNodeById(comfyPage, nodeId) + expect(before).not.toBeNull() + expect(before!.ghost).toBe(true) + + await comfyPage.page.mouse.click(centerX, centerY) + await comfyPage.nextFrame() + + const after = await getNodeById(comfyPage, nodeId) + expect(after).not.toBeNull() + expect(after!.ghost).toBe(false) + }) + + test('Escape cancels ghost placement', async ({ comfyPage }) => { + const { nodeId } = await addGhostAtCenter(comfyPage) + + const before = await getNodeById(comfyPage, nodeId) + expect(before).not.toBeNull() + expect(before!.ghost).toBe(true) + + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + const after = await getNodeById(comfyPage, nodeId) + expect(after).toBeNull() + }) + + test('Delete cancels ghost placement', async ({ comfyPage }) => { + const { nodeId } = await addGhostAtCenter(comfyPage) + + const before = await getNodeById(comfyPage, nodeId) + expect(before).not.toBeNull() + expect(before!.ghost).toBe(true) + + await comfyPage.page.keyboard.press('Delete') + await comfyPage.nextFrame() + + const after = await getNodeById(comfyPage, nodeId) + expect(after).toBeNull() + }) + + test('Backspace cancels ghost placement', async ({ comfyPage }) => { + const { nodeId } = await addGhostAtCenter(comfyPage) + + const before = await getNodeById(comfyPage, nodeId) + expect(before).not.toBeNull() + expect(before!.ghost).toBe(true) + + await comfyPage.page.keyboard.press('Backspace') + await comfyPage.nextFrame() + + const after = await getNodeById(comfyPage, nodeId) + expect(after).toBeNull() + }) + + test('right-click cancels ghost placement', async ({ comfyPage }) => { + const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage) + + const before = await getNodeById(comfyPage, nodeId) + expect(before).not.toBeNull() + expect(before!.ghost).toBe(true) + + await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' }) + await comfyPage.nextFrame() + + const after = await getNodeById(comfyPage, nodeId) + expect(after).toBeNull() + }) + }) +} diff --git a/src/components/graph/NodeTooltip.vue b/src/components/graph/NodeTooltip.vue index c803c05f5..53af7c95b 100644 --- a/src/components/graph/NodeTooltip.vue +++ b/src/components/graph/NodeTooltip.vue @@ -61,7 +61,7 @@ async function showTooltip(tooltip: string | null | undefined) { function onIdle() { const { canvas } = comfyApp const node = canvas?.node_over - if (!node) return + if (!node || node.flags?.ghost) return const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 } const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? ''] diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 1d2b023f5..8958655ff 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -70,6 +70,7 @@ export interface VueNodeData { color?: string flags?: { collapsed?: boolean + ghost?: boolean pinned?: boolean } hasErrors?: boolean @@ -526,6 +527,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { } }) break + case 'flags.ghost': + vueNodeData.set(nodeId, { + ...currentData, + flags: { + ...currentData.flags, + ghost: Boolean(propertyEvent.newValue) + } + }) + break case 'flags.pinned': vueNodeData.set(nodeId, { ...currentData, diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index c567335f8..d26734664 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -102,6 +102,16 @@ export interface LGraphConfig { links_ontop?: boolean } +/** Options for {@link LGraph.add} method. */ +interface GraphAddOptions { + /** If true, skip recomputing execution order after adding the node. */ + skipComputeOrder?: boolean + /** If true, the node will be semi-transparent and follow the cursor until placed or cancelled. */ + ghost?: boolean + /** Mouse event for ghost placement. Used to position node under cursor. */ + dragEvent?: MouseEvent +} + export interface GroupNodeConfigEntry { input?: Record output?: Record @@ -862,12 +872,35 @@ export class LGraph /** * Adds a new node instance to this graph * @param node the instance of the node + * @param options Additional options for adding the node */ add( node: LGraphNode | LGraphGroup, - skip_compute_order?: boolean + options?: GraphAddOptions + ): LGraphNode | null | undefined + /** + * Adds a new node instance to this graph + * @param node the instance of the node + * @param skipComputeOrder If true, skip recomputing execution order + * @deprecated Use options object instead + */ + add( + node: LGraphNode | LGraphGroup, + skipComputeOrder?: boolean + ): LGraphNode | null | undefined + add( + node: LGraphNode | LGraphGroup, + skipComputeOrderOrOptions?: boolean | GraphAddOptions ): LGraphNode | null | undefined { if (!node) return + + // Handle backwards compatibility: 2nd arg can be boolean or options + const opts: GraphAddOptions = + typeof skipComputeOrderOrOptions === 'object' + ? skipComputeOrderOrOptions + : { skipComputeOrder: skipComputeOrderOrOptions ?? false } + const shouldSkipComputeOrder = opts.skipComputeOrder ?? false + const { state } = this // Ensure created items are snapped @@ -914,6 +947,11 @@ export class LGraph } } + // Set ghost flag before registration so VueNodeData picks it up + if (opts.ghost) { + node.flags.ghost = true + } + node.graph = this this._version++ @@ -924,13 +962,17 @@ export class LGraph if (this.config.align_to_grid) node.alignToGrid() - if (!skip_compute_order) this.updateExecutionOrder() + if (!shouldSkipComputeOrder) this.updateExecutionOrder() this.onNodeAdded?.(node) this.setDirtyCanvas(true) this.change() + if (opts.ghost) { + this.canvasAction((c) => c.startGhostPlacement(node, opts.dragEvent)) + } + // to chain actions return node } diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index cc88b14f2..e131d877b 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -203,6 +203,9 @@ interface LGraphCanvasState { * Downstream consumers may reset to false once actioned. */ selectionChanged: boolean + + /** ID of node currently in ghost placement mode (semi-transparent, following cursor). */ + ghostNodeId: NodeId | null } /** @@ -313,7 +316,8 @@ export class LGraphCanvas implements CustomEventDispatcher readOnly: false, hoveringOver: CanvasItem.Nothing, shouldSetCursor: true, - selectionChanged: false + selectionChanged: false, + ghostNodeId: null } private _subgraph?: Subgraph @@ -2163,6 +2167,14 @@ export class LGraphCanvas implements CustomEventDispatcher } processMouseDown(e: MouseEvent): void { + if (this.state.ghostNodeId != null) { + if (e.button === 0) this.finalizeGhostPlacement(false) + if (e.button === 2) this.finalizeGhostPlacement(true) + e.stopPropagation() + e.preventDefault() + return + } + if ( this.dragZoomEnabled && e.ctrlKey && @@ -3553,6 +3565,76 @@ export class LGraphCanvas implements CustomEventDispatcher this.onNodeMoved?.(findFirstNode(this.selectedItems)) } + /** + * Starts ghost placement mode for a node. + * The node will be semi-transparent and follow the cursor until the user + * clicks to place it, or presses Escape/right-clicks to cancel. + * @param node The node to place + * @param dragEvent Optional mouse event for positioning under cursor + */ + startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void { + this.emitBeforeChange() + this.graph?.beforeChange() + + if (dragEvent) { + this.adjustMouseEvent(dragEvent) + const e = dragEvent as CanvasPointerEvent + node.pos[0] = e.canvasX - node.size[0] / 2 + node.pos[1] = e.canvasY + 10 + // Update last_mouse to prevent jump on first drag move + this.last_mouse = [e.clientX, e.clientY] + } else { + node.pos[0] = this.graph_mouse[0] - node.size[0] / 2 + node.pos[1] = this.graph_mouse[1] + 10 + } + + // Sync position to layout store for Vue node rendering + if (LiteGraph.vueNodesMode) { + const mutations = this.initLayoutMutations() + mutations.moveNode(node.id, { x: node.pos[0], y: node.pos[1] }) + } + + this.state.ghostNodeId = node.id + + this.deselectAll() + this.select(node) + this.isDragging = true + } + + /** + * Finalizes ghost placement mode. + * @param cancelled If true, the node is removed; otherwise it's placed + */ + finalizeGhostPlacement(cancelled: boolean): void { + const nodeId = this.state.ghostNodeId + if (nodeId == null) return + + this.state.ghostNodeId = null + this.isDragging = false + + const node = this.graph?.getNodeById(nodeId) + if (!node) return + + if (cancelled) { + this.deselect(node) + this.graph?.remove(node) + } else { + delete node.flags.ghost + this.graph?.trigger('node:property:changed', { + nodeId: node.id, + property: 'flags.ghost', + oldValue: true, + newValue: false + }) + } + + this.dirty_canvas = true + this.dirty_bgcanvas = true + + this.graph?.afterChange() + this.emitAfterChange() + } + /** * Called when a mouse up event has to be processed */ @@ -3723,6 +3805,17 @@ export class LGraphCanvas implements CustomEventDispatcher const { graph } = this if (!graph) return + // Cancel ghost placement + if ( + (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') && + this.state.ghostNodeId != null + ) { + this.finalizeGhostPlacement(true) + e.stopPropagation() + e.preventDefault() + return + } + let block_default = false // @ts-expect-error EventTarget.localName is not in standard types if (e.target.localName == 'input') return @@ -5805,6 +5898,7 @@ export class LGraphCanvas implements CustomEventDispatcher } private getNodeModeAlpha(node: LGraphNode) { + if (node.flags.ghost) return 0.3 return node.mode === LGraphEventMode.BYPASS ? 0.2 : node.mode === LGraphEventMode.NEVER diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index 9df7339af..c189481c8 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -332,6 +332,8 @@ export interface INodeFlags { collapsed?: boolean /** Configuration setting for {@link LGraphNode.connectInputToOutput} */ keepAllLinksOnBypass?: boolean + /** Node is in ghost placement mode (semi-transparent, following cursor) */ + ghost?: boolean } /** diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 5385ed7c1..cec183c9f 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -29,7 +29,7 @@ 'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver }, - shouldHandleNodePointerEvents + shouldHandleNodePointerEvents && !nodeData.flags?.ghost ? 'pointer-events-auto' : 'pointer-events-none', !isCollapsed && ' pb-1' @@ -268,6 +268,8 @@ const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER) const nodeOpacity = computed(() => { const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1 + if (nodeData.flags?.ghost) return globalOpacity * 0.3 + // For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity if (bypassed.value || muted.value) { return globalOpacity * 0.5