mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
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:
107
src/lib/litegraph/src/LGraphCanvas.ghostAutoPan.test.ts
Normal file
107
src/lib/litegraph/src/LGraphCanvas.ghostAutoPan.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user