mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
175
src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts
Normal file
175
src/lib/litegraph/src/LGraphCanvas.linkDragAutoPan.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
213
src/renderer/core/canvas/useAutoPan.test.ts
Normal file
213
src/renderer/core/canvas/useAutoPan.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
103
src/renderer/core/canvas/useAutoPan.ts
Normal file
103
src/renderer/core/canvas/useAutoPan.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user