feat: Add edge panning to ghost node placement (#10308)

## Summary

Enables edge panning when placing a ghost-node from the search

## Changes

- **What**: 
- registers document level listeners so dragging over UI elements isnt
blocked

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c3bda3c5-8255-4dda-a6be-6ef306af2244

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10308-feat-Add-edge-panning-to-ghost-node-placement-3286d73d36508151b645ec3e860bc017)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-19 16:21:01 +00:00
committed by GitHub
parent 43ba0a9a14
commit be6c64c75b
4 changed files with 175 additions and 36 deletions

View File

@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
setActor: vi.fn()
}
}))
describe('LGraphCanvas ghost placement auto-pan', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
let graph: LGraph
let node: LGraphNode
beforeEach(() => {
vi.useFakeTimers()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
document.body.appendChild(canvasElement)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
node = new LGraphNode('test')
node.size = [200, 100]
graph.add(node)
// Near left edge so autopan fires by default
canvas.mouse[0] = 5
canvas.mouse[1] = 300
})
afterEach(() => {
canvasElement.remove()
vi.useRealTimers()
})
it('moves the ghost node when pointer is near edge', () => {
canvas.startGhostPlacement(node)
const posXBefore = node.pos[0]
vi.advanceTimersByTime(16)
expect(node.pos[0]).not.toBe(posXBefore)
})
it('does not pan when pointer is in the center', () => {
canvas.mouse[0] = 400
canvas.startGhostPlacement(node)
const offsetBefore = [...canvas.ds.offset]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
})
it('cleans up autopan and document listener on finalize', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener')
canvas.startGhostPlacement(node)
expect(canvas['_autoPan']).not.toBeNull()
canvas.finalizeGhostPlacement(false)
expect(canvas['_autoPan']).toBeNull()
expect(removeSpy).toHaveBeenCalledWith('pointermove', expect.any(Function))
removeSpy.mockRestore()
})
it('survives linkConnector reset during ghost placement', () => {
canvas.startGhostPlacement(node)
canvas.linkConnector.reset()
expect(canvas['_autoPan']).not.toBeNull()
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).not.toBe(0)
})
})

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
@@ -22,40 +23,9 @@ describe('LGraphCanvas link drag auto-pan', () => {
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,

View File

@@ -682,6 +682,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
private _visibleReroutes: Set<Reroute> = new Set()
private _autoPan: AutoPanController | null = null
private _ghostPointerHandler: ((e: PointerEvent) => void) | null = null
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
@@ -837,8 +838,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// @deprecated Workaround: Keep until connecting_links is removed.
this.linkConnector.events.addEventListener('reset', () => {
this._autoPan?.stop()
this._autoPan = null
// Only stop link-drag autoPan; ghost placement manages its own.
if (this.state.ghostNodeId == null) {
this._autoPan?.stop()
this._autoPan = null
}
this.connecting_links = null
this.dirty_bgcanvas = true
})
@@ -3607,6 +3611,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.processSelect(item, pointer.eDown, sticky)
this.isDragging = true
this._startNodeAutoPan()
}
private _startNodeAutoPan(): void {
this._autoPan = new AutoPanController({
canvas: this.canvas,
ds: this.ds,
@@ -3675,6 +3683,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.deselectAll()
this.select(node)
this.isDragging = true
this._startNodeAutoPan()
// Listen on document so autopan works when the pointer is over DOM elements.
this._ghostPointerHandler = (e: PointerEvent) => {
// Trigger mouse move so the ghost node follows the cursor the same as when dragging a node.
this.processMouseMove(e)
}
document.addEventListener('pointermove', this._ghostPointerHandler)
// When the pointer leaves the viewport quickly, ensure we still trigger auto-pan.
document.documentElement.addEventListener(
'pointerleave',
this._ghostPointerHandler
)
}
/**
@@ -3687,6 +3709,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.state.ghostNodeId = null
this.isDragging = false
this._autoPan?.stop()
this._autoPan = null
if (this._ghostPointerHandler) {
document.removeEventListener('pointermove', this._ghostPointerHandler)
document.documentElement.removeEventListener(
'pointerleave',
this._ghostPointerHandler
)
this._ghostPointerHandler = null
}
const node = this.graph?.getNodeById(nodeId)
if (!node) return

View File

@@ -125,7 +125,36 @@ export function createMockCanvasRenderingContext2D(
overrides: Partial<CanvasRenderingContext2D> = {}
): CanvasRenderingContext2D {
const partial: Partial<CanvasRenderingContext2D> = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn(() => ({ width: 10 }) as TextMetrics),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi.fn(
() => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) as DOMMatrix
),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
...overrides
}
return partial as CanvasRenderingContext2D