Autopan canvas when dragging nodes/links to edges (#8773)

## Summary

Adds autopanning so the canvas moves when you drag a node/link to the
side of the canvas

## Changes

- **What**: 
- adds autopan controller that runs on animation frame timer to check
autopan speed
- extracts updateNodePositions for reuse
- specific handling for vue vs litegraph modes
- adds max speed setting, allowing user to set 0 for disabling

## Screenshots (if applicable)

https://github.com/user-attachments/assets/1290ae6d-b2f0-4d63-8955-39b933526874

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8773-Autopan-canvas-when-dragging-nodes-links-to-edges-3036d73d365081869a58ca5978f15f80)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
pythongosssss
2026-03-19 11:12:06 +00:00
committed by GitHub
parent 77ddda9d3c
commit 3ed88fbe68
14 changed files with 1183 additions and 130 deletions

View File

@@ -79,6 +79,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)

View File

@@ -123,6 +123,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
test('Can pin and unpin', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'

View File

@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas link drag auto-pan', () => {
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.useFakeTimers()
canvasElement = document.createElement('canvas')
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.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
const graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
})
afterEach(() => {
canvas.pointer.finally?.()
vi.useRealTimers()
})
function startLinkDrag() {
canvas['_linkConnectorDrop']()
}
it('starts auto-pan when link drag begins', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
expect(canvas['_autoPan']).not.toBeNull()
})
it('keeps graph_mouse consistent with offset after auto-pan', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
vi.advanceTimersByTime(16)
const { scale } = canvas.ds
expect(canvas.graph_mouse[0]).toBeCloseTo(
canvas.mouse[0] / scale - canvas.ds.offset[0]
)
expect(canvas.graph_mouse[1]).toBeCloseTo(
canvas.mouse[1] / scale - canvas.ds.offset[1]
)
})
it('keeps graph_mouse consistent with zoom applied', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
canvas.ds.scale = 2
startLinkDrag()
vi.advanceTimersByTime(16)
expect(canvas.graph_mouse[0]).toBeCloseTo(
canvas.mouse[0] / 2 - canvas.ds.offset[0]
)
expect(canvas.graph_mouse[1]).toBeCloseTo(
canvas.mouse[1] / 2 - canvas.ds.offset[1]
)
})
it('pans the viewport when pointer is near edge', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
const offsetBefore = canvas.ds.offset[0]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).not.toBe(offsetBefore)
})
it('marks canvas dirty when auto-pan fires', () => {
canvas.mouse[0] = 5
canvas.mouse[1] = 300
startLinkDrag()
canvas.dirty_canvas = false
canvas.dirty_bgcanvas = false
vi.advanceTimersByTime(16)
expect(canvas.dirty_canvas).toBe(true)
expect(canvas.dirty_bgcanvas).toBe(true)
})
it('stops auto-pan when pointer.finally fires', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
expect(canvas['_autoPan']).not.toBeNull()
canvas.pointer.finally!()
expect(canvas['_autoPan']).toBeNull()
})
it('does not pan when pointer is in the center', () => {
canvas.mouse[0] = 400
canvas.mouse[1] = 300
startLinkDrag()
const offsetBefore = [...canvas.ds.offset]
vi.advanceTimersByTime(16)
expect(canvas.ds.offset[0]).toBe(offsetBefore[0])
expect(canvas.ds.offset[1]).toBe(offsetBefore[1])
})
})

View File

@@ -3,6 +3,7 @@ import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
@@ -532,6 +533,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
readonly pointer: CanvasPointer
zoom_modify_alpha: boolean
zoom_speed: number
auto_pan_speed: number
node_title_color: string
default_link_color: string
default_connection_color: {
@@ -679,6 +681,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
highlighted_links: Dictionary<boolean> = {}
private _visibleReroutes: Set<Reroute> = new Set()
private _autoPan: AutoPanController | null = null
dirty_canvas: boolean = true
dirty_bgcanvas: boolean = true
@@ -834,6 +837,8 @@ 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
this.connecting_links = null
this.dirty_bgcanvas = true
})
@@ -901,6 +906,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.zoom_modify_alpha = true
// in range (1.01, 2.5). Less than 1 will invert the zoom direction
this.zoom_speed = 1.1
this.auto_pan_speed = 15
this.node_title_color = LiteGraph.NODE_TITLE_COLOR
this.default_link_color = LiteGraph.LINK_COLOR
@@ -2070,7 +2076,28 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!graph) throw new NullGraphError()
pointer.onDragEnd = (upEvent) => linkConnector.dropLinks(graph, upEvent)
pointer.finally = () => this.linkConnector.reset(true)
pointer.finally = () => {
this._autoPan?.stop()
this._autoPan = null
this.linkConnector.reset(true)
}
this._autoPan = new AutoPanController({
canvas: this.canvas,
ds: this.ds,
maxPanSpeed: this.auto_pan_speed,
onPan: () => {
const rect = this.canvas.getBoundingClientRect()
const { scale } = this.ds
this.graph_mouse[0] =
(this.mouse[0] - rect.left) / scale - this.ds.offset[0]
this.graph_mouse[1] =
(this.mouse[1] - rect.top) / scale - this.ds.offset[1]
this._dirty()
}
})
this._autoPan.updatePointer(this.mouse[0], this.mouse[1])
this._autoPan.start()
}
/**
@@ -3282,7 +3309,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
(this.allow_interaction || node?.flags.allow_interaction) &&
!this.read_only
) {
if (linkConnector.isConnecting) this.dirty_canvas = true
if (linkConnector.isConnecting) {
this._autoPan?.updatePointer(e.clientX, e.clientY)
this.dirty_canvas = true
}
// remove mouseover flag
this.updateMouseOverNodes(node, e)
@@ -3488,6 +3518,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Items being dragged
if (this.isDragging) {
this._autoPan?.updatePointer(e.clientX, e.clientY)
const selected = this.selectedItems
const allItems = e.ctrlKey ? selected : getAllNestedItems(selected)
@@ -3566,12 +3598,36 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
this.isDragging = false
this._autoPan?.stop()
this._autoPan = null
this.graph?.afterChange()
this.emitAfterChange()
}
this.processSelect(item, pointer.eDown, sticky)
this.isDragging = true
this._autoPan = new AutoPanController({
canvas: this.canvas,
ds: this.ds,
maxPanSpeed: this.auto_pan_speed,
onPan: (panX, panY) => {
const selected = this.selectedItems
const allItems = getAllNestedItems(selected)
if (LiteGraph.vueNodesMode) {
this.moveChildNodesInGroupVueMode(allItems, panX, panY)
} else {
for (const item of allItems) {
item.move(panX, panY, true)
}
}
this._dirty()
}
})
this._autoPan.updatePointer(this.mouse[0], this.mouse[1])
this._autoPan.start()
}
/**

View File

@@ -120,6 +120,10 @@
"name": "Live selection",
"tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools."
},
"Comfy_Graph_AutoPanSpeed": {
"name": "Auto-pan speed",
"tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning."
},
"Comfy_Graph_ZoomSpeed": {
"name": "Canvas zoom speed"
},

View File

@@ -31,6 +31,13 @@ export const useLitegraphSettings = () => {
}
})
watchEffect(() => {
const autoPanSpeed = settingStore.get('Comfy.Graph.AutoPanSpeed')
if (canvasStore.canvas) {
canvasStore.canvas.auto_pan_speed = autoPanSpeed
}
})
watchEffect(() => {
LiteGraph.snaps_for_comfy = settingStore.get(
'Comfy.Node.AutoSnapLinkToSlot'

View File

@@ -311,6 +311,20 @@ export const CORE_SETTINGS: SettingParams[] = [
step: 0.01
}
},
{
id: 'Comfy.Graph.AutoPanSpeed',
category: ['LiteGraph', 'Canvas', 'AutoPanSpeed'],
name: 'Auto-pan speed',
tooltip:
'Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning.',
type: 'slider',
defaultValue: 15,
attrs: {
min: 0,
max: 30,
step: 1
}
},
// Bookmarks are stored in the settings store.
{
id: 'Comfy.NodeLibrary.NewDesign',

View File

@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale'
import {
AutoPanController,
calculateEdgePanSpeed
} from '@/renderer/core/canvas/useAutoPan'
describe('calculateEdgePanSpeed', () => {
const MAX = 15
it('returns 0 when pointer is in the center', () => {
expect(calculateEdgePanSpeed(500, 0, 1000, 1, MAX)).toBe(0)
})
it('returns negative speed near the left/top edge', () => {
const speed = calculateEdgePanSpeed(10, 0, 1000, 1, MAX)
expect(speed).toBeLessThan(0)
})
it('returns positive speed near the right/bottom edge', () => {
const speed = calculateEdgePanSpeed(990, 0, 1000, 1, MAX)
expect(speed).toBeGreaterThan(0)
})
it('returns max speed at the exact edge', () => {
const speed = calculateEdgePanSpeed(0, 0, 1000, 1, MAX)
expect(speed).toBe(-15)
})
it('returns 0 at exactly the threshold boundary', () => {
const speed = calculateEdgePanSpeed(50, 0, 1000, 1, MAX)
expect(speed).toBe(0)
})
it('scales speed linearly with edge proximity', () => {
const halfwaySpeed = calculateEdgePanSpeed(25, 0, 1000, 1, MAX)
const quarterSpeed = calculateEdgePanSpeed(37.5, 0, 1000, 1, MAX)
expect(halfwaySpeed).toBeCloseTo(-15 * 0.5)
expect(quarterSpeed).toBeCloseTo(-15 * 0.25)
})
it('divides speed by scale (zoom level)', () => {
const speedAtScale1 = calculateEdgePanSpeed(0, 0, 1000, 1, MAX)
const speedAtScale2 = calculateEdgePanSpeed(0, 0, 1000, 2, MAX)
expect(speedAtScale2).toBe(speedAtScale1 / 2)
})
it('returns max speed when pointer is outside bounds', () => {
expect(calculateEdgePanSpeed(-10, 0, 1000, 1, MAX)).toBe(-15)
expect(calculateEdgePanSpeed(1010, 0, 1000, 1, MAX)).toBe(15)
})
it('returns 0 when maxPanSpeed is 0 (disabled)', () => {
expect(calculateEdgePanSpeed(0, 0, 1000, 1, 0)).toBe(0)
expect(calculateEdgePanSpeed(10, 0, 1000, 1, 0)).toBe(0)
})
it('uses custom maxPanSpeed', () => {
const speed = calculateEdgePanSpeed(0, 0, 1000, 1, 30)
expect(speed).toBe(-30)
})
})
describe('AutoPanController', () => {
let mockCanvas: HTMLCanvasElement
let mockDs: DragAndScale
let onPanMock: ReturnType<typeof vi.fn<(dx: number, dy: number) => void>>
let controller: AutoPanController
beforeEach(() => {
vi.useFakeTimers()
mockCanvas = {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {}
})
} as unknown as HTMLCanvasElement
mockDs = {
offset: [0, 0],
scale: 1
} as unknown as DragAndScale
onPanMock = vi.fn<(dx: number, dy: number) => void>()
controller = new AutoPanController({
canvas: mockCanvas,
ds: mockDs,
maxPanSpeed: 15,
onPan: onPanMock
})
})
afterEach(() => {
controller.stop()
vi.useRealTimers()
})
it('does not pan when pointer is in the center', () => {
controller.updatePointer(400, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).not.toHaveBeenCalled()
})
it('pans when pointer is near the right edge', () => {
controller.updatePointer(790, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBe(0)
})
it('pans when pointer is near the bottom edge', () => {
controller.updatePointer(400, 590)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBe(0)
expect(dy).toBeGreaterThan(0)
})
it('pans diagonally when pointer is near a corner', () => {
controller.updatePointer(790, 590)
controller.start()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalled()
const [dx, dy] = onPanMock.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBeGreaterThan(0)
})
it('updates ds.offset when panning', () => {
controller.updatePointer(0, 300)
controller.start()
vi.advanceTimersByTime(16)
expect(mockDs.offset[0]).toBeGreaterThan(0)
expect(mockDs.offset[1]).toBe(0)
})
it('accounts for zoom level in offset changes', () => {
controller.updatePointer(0, 300)
controller.start()
vi.advanceTimersByTime(16)
const offsetAtScale1 = mockDs.offset[0]
controller.stop()
mockDs.offset[0] = 0
mockDs.scale = 2
controller.start()
vi.advanceTimersByTime(16)
const offsetAtScale2 = mockDs.offset[0]
expect(offsetAtScale2).toBeCloseTo(offsetAtScale1 / 2)
})
it('stops panning when stop() is called', () => {
controller.updatePointer(790, 300)
controller.start()
vi.advanceTimersByTime(16)
const callCount = onPanMock.mock.calls.length
expect(callCount).toBeGreaterThan(0)
controller.stop()
vi.advanceTimersByTime(16)
expect(onPanMock).toHaveBeenCalledTimes(callCount)
})
it('does not pan when maxPanSpeed is 0', () => {
const disabledController = new AutoPanController({
canvas: mockCanvas,
ds: mockDs,
maxPanSpeed: 0,
onPan: onPanMock
})
disabledController.updatePointer(0, 0)
disabledController.start()
vi.advanceTimersByTime(16)
expect(onPanMock).not.toHaveBeenCalled()
disabledController.stop()
})
})

View File

@@ -0,0 +1,103 @@
import { useRafFn } from '@vueuse/core'
import type { DragAndScale } from '@/lib/litegraph/src/DragAndScale'
const EDGE_THRESHOLD = 50
interface AutoPanOptions {
canvas: HTMLCanvasElement
ds: DragAndScale
maxPanSpeed: number
onPan: (canvasDeltaX: number, canvasDeltaY: number) => void
}
/**
* Calculates the pan speed for a single axis based on distance from the edge.
* Returns negative speed for left/top edges, positive for right/bottom edges,
* or 0 if the pointer is not near any edge. Pans at max speed when the
* pointer is outside the bounds (e.g. dragged outside the window).
*/
export function calculateEdgePanSpeed(
pointerPos: number,
minBound: number,
maxBound: number,
scale: number,
maxPanSpeed: number
): number {
if (maxPanSpeed <= 0) return 0
const distFromMin = pointerPos - minBound
const distFromMax = maxBound - pointerPos
if (distFromMin < 0) return -maxPanSpeed / scale
if (distFromMax < 0) return maxPanSpeed / scale
if (distFromMin < EDGE_THRESHOLD) {
return (-maxPanSpeed * (1 - distFromMin / EDGE_THRESHOLD)) / scale
}
if (distFromMax < EDGE_THRESHOLD) {
return (maxPanSpeed * (1 - distFromMax / EDGE_THRESHOLD)) / scale
}
return 0
}
export class AutoPanController {
private pointerX = 0
private pointerY = 0
private readonly canvas: HTMLCanvasElement
private readonly ds: DragAndScale
private readonly maxPanSpeed: number
private readonly onPan: (dx: number, dy: number) => void
private readonly raf: ReturnType<typeof useRafFn>
constructor(options: AutoPanOptions) {
this.canvas = options.canvas
this.ds = options.ds
this.maxPanSpeed = options.maxPanSpeed
this.onPan = options.onPan
this.raf = useRafFn(() => this.tick(), { immediate: false })
}
updatePointer(screenX: number, screenY: number) {
this.pointerX = screenX
this.pointerY = screenY
}
start() {
this.raf.resume()
}
stop() {
this.raf.pause()
}
private tick() {
const rect = this.canvas.getBoundingClientRect()
const scale = this.ds.scale
const panX = calculateEdgePanSpeed(
this.pointerX,
rect.left,
rect.right,
scale,
this.maxPanSpeed
)
const panY = calculateEdgePanSpeed(
this.pointerY,
rect.top,
rect.bottom,
scale,
this.maxPanSpeed
)
if (panX === 0 && panY === 0) return
this.ds.offset[0] -= panX
this.ds.offset[1] -= panY
this.onPan(panX, panY)
}
}

View File

@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
capturedOnPan,
capturedAutoPan,
capturedHandlers,
mockDs,
mockSetDirty,
mockLinkConnector,
mockAdapter
} = vi.hoisted(() => ({
capturedOnPan: { current: null as ((dx: number, dy: number) => void) | null },
capturedAutoPan: {
current: null as {
updatePointer: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} | null
},
capturedHandlers: {} as Record<string, (...args: unknown[]) => void>,
mockDs: { offset: [0, 0] as [number, number], scale: 1 },
mockSetDirty: vi.fn(),
mockLinkConnector: {
isConnecting: false,
state: { snapLinksPos: null as [number, number] | null },
events: {}
},
mockAdapter: {
beginFromOutput: vi.fn(),
beginFromInput: vi.fn(),
reset: vi.fn(),
renderLinks: [] as unknown[],
linkConnector: null as unknown,
isInputValidDrop: vi.fn(() => false),
isOutputValidDrop: vi.fn(() => false),
dropOnCanvas: vi.fn()
}
}))
mockAdapter.linkConnector = mockLinkConnector
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
constructor(opts: { onPan: (dx: number, dy: number) => void }) {
capturedOnPan.current = opts.onPan
capturedAutoPan.current = this as typeof capturedAutoPan.current
}
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
ds: mockDs,
graph: {
getNodeById: (id: string) => ({
id,
inputs: [],
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
}),
getLink: () => null,
getReroute: () => null
},
linkConnector: mockLinkConnector,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600
})
},
setDirty: mockSetDirty
}
}
}))
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
createLinkConnectorAdapter: () => mockAdapter
}))
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => {
const pointer = { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }
return {
useSlotLinkDragUIState: () => ({
state: {
active: false,
pointerId: null,
source: null,
pointer,
candidate: null,
compatible: new Map()
},
beginDrag: vi.fn(),
endDrag: vi.fn(),
updatePointerPosition: (
cx: number,
cy: number,
canX: number,
canY: number
) => {
pointer.client.x = cx
pointer.client.y = cy
pointer.canvas.x = canX
pointer.canvas.y = canY
},
setCandidate: vi.fn(),
setCompatibleForKey: vi.fn(),
clearCompatible: vi.fn()
})
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: (pos: [number, number]): [number, number] => [
pos[0] / (mockDs.scale || 1) - mockDs.offset[0],
pos[1] / (mockDs.scale || 1) - mockDs.offset[1]
]
})
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getSlotLayout: (_key: string) => ({
nodeId: 'node1',
index: 0,
type: 'output',
position: { x: 100, y: 200 }
}),
getAllSlotKeys: () => [],
getRerouteLayout: () => null,
queryRerouteAtPoint: () => null
}
}))
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
getSlotKey: (...args: unknown[]) => args.join('-')
}))
vi.mock('@/renderer/core/canvas/interaction/canvasPointerEvent', () => ({
toCanvasPointerEvent: (e: PointerEvent) => e,
clearCanvasPointerHistory: vi.fn()
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/slotLinkDragContext',
() => ({
createSlotLinkDragContext: () => ({
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotKey: null,
lastPointerTargetNodeId: null,
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
reset: vi.fn(),
dispose: vi.fn()
})
})
)
vi.mock('@/renderer/extensions/vueNodes/utils/eventUtils', () => ({
augmentToCanvasPointerEvent: vi.fn()
}))
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
resolveSlotTargetCandidate: () => null,
resolveNodeSurfaceSlotCandidate: () => null
}))
vi.mock('@vueuse/core', () => ({
useEventListener: (event: string, handler: (...args: unknown[]) => void) => {
capturedHandlers[event] = handler
return vi.fn()
},
tryOnScopeDispose: () => {}
}))
vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({
schedule: () => {},
cancel: () => {},
flush: fn
})
}))
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
function pointerEvent(
clientX: number,
clientY: number,
pointerId = 1
): PointerEvent {
return {
clientX,
clientY,
button: 0,
pointerId,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
target: document.createElement('div'),
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as unknown as PointerEvent
}
function startDrag() {
const { onPointerDown } = useSlotLinkInteraction({
nodeId: 'node1',
index: 0,
type: 'output'
})
onPointerDown(pointerEvent(400, 300))
}
describe('useSlotLinkInteraction auto-pan', () => {
beforeEach(() => {
capturedOnPan.current = null
capturedAutoPan.current = null
for (const k of Object.keys(capturedHandlers)) {
delete capturedHandlers[k]
}
mockDs.offset = [0, 0]
mockDs.scale = 1
mockSetDirty.mockClear()
mockAdapter.beginFromOutput.mockClear()
mockLinkConnector.state.snapLinksPos = null
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts auto-pan when link drag begins', () => {
startDrag()
expect(capturedAutoPan.current).not.toBeNull()
expect(capturedAutoPan.current!.start).toHaveBeenCalled()
})
it('updates snapLinksPos and marks dirty when onPan fires', () => {
startDrag()
mockSetDirty.mockClear()
mockDs.offset = [-10, -5]
capturedOnPan.current!(10, 5)
expect(mockLinkConnector.state.snapLinksPos).toEqual([410, 305])
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
})
it('forwards pointer position to auto-pan during drag', () => {
startDrag()
const moveHandler = capturedHandlers['pointermove']
moveHandler(pointerEvent(790, 300))
expect(capturedAutoPan.current!.updatePointer).toHaveBeenCalledWith(
790,
300
)
})
it('stops auto-pan on cleanup', () => {
startDrag()
const upHandler = capturedHandlers['pointerup']
upHandler(pointerEvent(400, 300))
expect(capturedAutoPan.current!.stop).toHaveBeenCalled()
})
})

View File

@@ -2,6 +2,7 @@ import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import type { Fn } from '@vueuse/core'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
@@ -124,6 +125,7 @@ export function useSlotLinkInteraction({
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
let autoPan: AutoPanController | null = null
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
@@ -283,6 +285,8 @@ export function useSlotLinkInteraction({
}
const cleanupInteraction = () => {
autoPan?.stop()
autoPan = null
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
@@ -411,6 +415,8 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return
event.stopPropagation()
autoPan?.updatePointer(event.clientX, event.clientY)
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,
@@ -738,6 +744,30 @@ export function useSlotLinkInteraction({
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
autoPan = new AutoPanController({
canvas: canvas.canvas,
ds: canvas.ds,
maxPanSpeed: canvas.auto_pan_speed,
onPan: () => {
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
state.pointer.client.x,
state.pointer.client.y
])
updatePointerPosition(
state.pointer.client.x,
state.pointer.client.y,
canvasX,
canvasY
)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [canvasX, canvasY]
}
canvas.setDirty(true, true)
}
})
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
canvas.setDirty(true, true)
}

View File

@@ -19,7 +19,18 @@ const testState = vi.hoisted(() => {
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos)
},
cancelAnimationFrame: vi.fn(),
requestAnimationFrameCallback: null as FrameRequestCallback | null
requestAnimationFrameCallback: null as FrameRequestCallback | null,
capturedOnPan: {
current: null as ((dx: number, dy: number) => void) | null
},
capturedAutoPanInstance: {
current: null as {
updatePointer: ReturnType<typeof vi.fn>
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} | null
},
mockDs: { offset: [0, 0] as [number, number], scale: 1 }
}
})
@@ -27,10 +38,34 @@ vi.mock('pinia', () => ({
storeToRefs: <T>(store: T) => store
}))
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
constructor(opts: { onPan: (dx: number, dy: number) => void }) {
testState.capturedOnPan.current = opts.onPan
testState.capturedAutoPanInstance.current = this
}
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
selectedNodeIds: testState.selectedNodeIds,
selectedItems: testState.selectedItems
selectedItems: testState.selectedItems,
canvas: {
ds: testState.mockDs,
auto_pan_speed: 10,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600
})
}
}
})
}))
@@ -58,7 +93,10 @@ vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
useTransformState: () => ({
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x, y })
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({
x: x / (testState.mockDs.scale || 1) - testState.mockDs.offset[0],
y: y / (testState.mockDs.scale || 1) - testState.mockDs.offset[1]
})
})
}))
@@ -66,8 +104,24 @@ vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: () => false
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: (fn: () => unknown) => fn
}))
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
function pointerEvent(clientX: number, clientY: number): PointerEvent {
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
return {
clientX,
clientY,
target,
pointerId: 1
} as unknown as PointerEvent
}
describe('useNodeDrag', () => {
beforeEach(() => {
testState.selectedNodeIds = ref(new Set<string>())
@@ -85,6 +139,10 @@ describe('useNodeDrag', () => {
)
testState.cancelAnimationFrame.mockReset()
testState.requestAnimationFrameCallback = null
testState.capturedOnPan.current = null
testState.capturedAutoPanInstance.current = null
testState.mockDs.offset = [0, 0]
testState.mockDs.scale = 1
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
testState.requestAnimationFrameCallback = cb
@@ -106,28 +164,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
{
clientX: 10,
clientY: 20
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 30,
clientY: 40,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(10, 20), '1')
handleDrag(pointerEvent(30, 40), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
@@ -147,28 +185,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
{
clientX: 5,
clientY: 10
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 25,
clientY: 30,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(5, 10), '1')
handleDrag(pointerEvent(25, 30), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
@@ -192,28 +210,8 @@ describe('useNodeDrag', () => {
const { startDrag, handleDrag, endDrag } = useNodeDrag()
startDrag(
{
clientX: 5,
clientY: 10
} as PointerEvent,
'1'
)
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 25,
clientY: 30,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
startDrag(pointerEvent(5, 10), '1')
handleDrag(pointerEvent(25, 30), '1')
endDrag({} as PointerEvent, '1')
expect(testState.cancelAnimationFrame).toHaveBeenCalledTimes(1)
@@ -232,3 +230,113 @@ describe('useNodeDrag', () => {
])
})
})
describe('useNodeDrag auto-pan', () => {
beforeEach(() => {
testState.selectedNodeIds = ref(new Set(['1']))
testState.selectedItems = ref<unknown[]>([])
testState.nodeLayouts.clear()
testState.nodeLayouts.set('1', {
position: { x: 100, y: 200 },
size: { width: 200, height: 100 }
})
testState.nodeLayouts.set('2', {
position: { x: 300, y: 400 },
size: { width: 200, height: 100 }
})
testState.mutationFns.setSource.mockReset()
testState.mutationFns.moveNode.mockReset()
testState.mutationFns.batchMoveNodes.mockReset()
testState.batchUpdateNodeBounds.mockReset()
testState.nodeSnap.shouldSnap.mockReset()
testState.nodeSnap.shouldSnap.mockReturnValue(false)
testState.nodeSnap.applySnapToPosition.mockReset()
testState.nodeSnap.applySnapToPosition.mockImplementation(
(pos: { x: number; y: number }) => pos
)
testState.cancelAnimationFrame.mockReset()
testState.requestAnimationFrameCallback = null
testState.capturedOnPan.current = null
testState.capturedAutoPanInstance.current = null
testState.mockDs.offset = [0, 0]
testState.mockDs.scale = 1
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
testState.requestAnimationFrameCallback = cb
return 1
})
vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame)
})
it('moves node when auto-pan shifts the canvas offset', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(750, 300), '1')
drag.handleDrag(pointerEvent(760, 300), '1')
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenLastCalledWith([
{ nodeId: '1', position: { x: 110, y: 200 } }
])
testState.mutationFns.batchMoveNodes.mockClear()
testState.mockDs.offset[0] -= 5
testState.capturedOnPan.current!(5, 0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 115, y: 200 } }
])
})
it('moves all selected nodes when auto-pan fires', () => {
testState.selectedNodeIds.value = new Set(['1', '2'])
const drag = useNodeDrag()
drag.startDrag(pointerEvent(750, 300), '1')
testState.mutationFns.batchMoveNodes.mockClear()
testState.mockDs.offset[0] -= 5
testState.capturedOnPan.current!(5, 0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
const calls = testState.mutationFns.batchMoveNodes.mock.calls[0][0]
const nodeIds = calls.map((u: { nodeId: string }) => u.nodeId)
expect(nodeIds).toContain('1')
expect(nodeIds).toContain('2')
})
it('updates auto-pan pointer on handleDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(790, 300), '1')
expect(
testState.capturedAutoPanInstance.current!.updatePointer
).toHaveBeenCalledWith(790, 300)
})
it('stops auto-pan on endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
expect(testState.capturedAutoPanInstance.current).not.toBeNull()
drag.endDrag(pointerEvent(400, 300), '1')
expect(testState.capturedAutoPanInstance.current!.stop).toHaveBeenCalled()
})
it('does not move nodes if onPan fires after endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
const onPan = testState.capturedOnPan.current!
drag.endDrag(pointerEvent(400, 300), '1')
testState.mutationFns.batchMoveNodes.mockClear()
onPan(5, 0)
expect(testState.mutationFns.batchMoveNodes).not.toHaveBeenCalled()
})
})

View File

@@ -3,6 +3,7 @@ import { toValue } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -32,6 +33,8 @@ function useNodeDragIndividual() {
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const canvasStore = useCanvasStore()
// Drag state
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
@@ -43,6 +46,11 @@ function useNodeDragIndividual() {
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
// Auto-pan state
let autoPan: AutoPanController | null = null
let lastPointerX = 0
let lastPointerY = 0
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
@@ -53,6 +61,8 @@ function useNodeDragIndividual() {
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
lastPointerX = event.clientX
lastPointerY = event.clientY
const selectedNodes = toValue(selectedNodeIds)
@@ -87,6 +97,97 @@ function useNodeDragIndividual() {
}
mutations.setSource(LayoutSource.Vue)
// Start auto-pan
const lgCanvas = canvasStore.canvas
if (lgCanvas?.ds) {
autoPan = new AutoPanController({
canvas: lgCanvas.canvas,
ds: lgCanvas.ds,
maxPanSpeed: lgCanvas.auto_pan_speed,
onPan: (panX, panY) => {
if (dragStartPos) {
dragStartPos.x += panX
dragStartPos.y += panY
}
if (otherSelectedNodesStartPositions) {
for (const pos of otherSelectedNodesStartPositions.values()) {
pos.x += panX
pos.y += panY
}
}
if (selectedGroups) {
for (const group of selectedGroups) {
group.move(panX, panY, true)
}
}
updateNodePositions(nodeId)
}
})
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
}
}
/**
* Recalculates all dragged node positions based on the current mouse
* position and canvas transform.
*/
function updateNodePositions(nodeId: NodeId) {
if (!dragStartPos || !dragStartMouse) return
const mouseDelta = {
x: lastPointerX - dragStartMouse.x,
y: lastPointerY - dragStartMouse.y
}
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Move drag updates in one transaction to avoid per-node notify fan-out.
const updates = [
{
nodeId,
position: {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
}
]
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
updates.push({
nodeId: otherNodeId,
position: {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
})
}
}
mutations.batchMoveNodes(updates)
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
}
function handleDrag(event: PointerEvent, nodeId: NodeId) {
@@ -102,67 +203,14 @@ function useNodeDragIndividual() {
// Delay capture to drag to allow for the Node cloning
target.setPointerCapture(pointerId)
}
lastPointerX = event.clientX
lastPointerY = event.clientY
autoPan?.updatePointer(event.clientX, event.clientY)
rafId = requestAnimationFrame(() => {
rafId = null
if (!dragStartPos || !dragStartMouse) return
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Move drag updates in one transaction to avoid per-node notify fan-out.
const updates = [{ nodeId, position: newPosition }]
// Include other selected nodes so multi-drag stays in lockstep.
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
updates.push({ nodeId: otherNodeId, position: newOtherPosition })
}
}
mutations.batchMoveNodes(updates)
// Move selected groups using frame delta (difference from last frame)
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
updateNodePositions(nodeId)
})
}
@@ -234,6 +282,10 @@ function useNodeDragIndividual() {
selectedGroups = null
lastCanvasDelta = null
// Stop auto-pan
autoPan?.stop()
autoPan = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null

View File

@@ -312,6 +312,7 @@ const zSettings = z.object({
'Comfy.EnableTooltips': z.boolean(),
'Comfy.EnableWorkflowViewRestore': z.boolean(),
'Comfy.FloatRoundingPrecision': z.number(),
'Comfy.Graph.AutoPanSpeed': z.number(),
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),