Node ghost mode when adding nodes (#8694)

## 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)
This commit is contained in:
pythongosssss
2026-02-06 13:42:38 -08:00
committed by GitHub
parent 8f301ec94b
commit 0e3314bbd3
7 changed files with 317 additions and 5 deletions

View File

@@ -0,0 +1,162 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[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()
})
})
}

View File

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

View File

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

View File

@@ -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<string, { name?: string; visible?: boolean }>
output?: Record<number, { name?: string; visible?: boolean }>
@@ -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
}

View File

@@ -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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
}
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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
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<LGraphCanvasEventMap>
}
private getNodeModeAlpha(node: LGraphNode) {
if (node.flags.ghost) return 0.3
return node.mode === LGraphEventMode.BYPASS
? 0.2
: node.mode === LGraphEventMode.NEVER

View File

@@ -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
}
/**

View File

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