Compare commits

...

8 Commits

Author SHA1 Message Date
Benjamin Lu
7c7eabb993 fix: address drag snapping review feedback 2026-03-18 20:04:10 -07:00
Benjamin Lu
45f8ec337a Prune snap candidates by proximity 2026-03-13 18:45:58 -07:00
Benjamin Lu
72381dcc38 Limit snap guide redraw to foreground 2026-03-13 18:15:15 -07:00
Benjamin Lu
94fb623fc8 Gate drag guides to vue mode 2026-03-13 17:06:30 -07:00
Benjamin Lu
a5b517a12f Update snapping setting version metadata 2026-03-13 16:53:42 -07:00
Benjamin Lu
8c8143b4bd Fix snap guide pointer interaction test mock 2026-03-13 15:19:19 -07:00
Benjamin Lu
8dd03d54da chore: satisfy drag snapping export usage 2026-03-13 12:09:21 -07:00
Benjamin Lu
fad0d17508 feat: add nodes 2 drag snapping 2026-03-13 12:08:49 -07:00
15 changed files with 1120 additions and 46 deletions

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Node Drag Snapping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvasOps.resetView()
await comfyPage.vueNodes.waitForNodes(6)
})
test('snaps a dragged node to another node in Vue nodes mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.AlignNodesWhileDragging',
true
)
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first()
const checkpointNode = comfyPage.vueNodes
.getNodeByTitle('Load Checkpoint')
.first()
const ksamplerHeader = ksamplerNode.locator('.lg-node-header')
const ksamplerBox = await ksamplerNode.boundingBox()
const checkpointBox = await checkpointNode.boundingBox()
const headerBox = await ksamplerHeader.boundingBox()
if (!ksamplerBox || !checkpointBox || !headerBox) {
throw new Error('Expected Vue node bounding boxes to be available')
}
const dragStart = {
x: headerBox.x + headerBox.width / 2,
y: headerBox.y + headerBox.height / 2
}
const targetLeft = checkpointBox.x + 5
const dragTarget = {
x: dragStart.x + (targetLeft - ksamplerBox.x),
y: dragStart.y
}
await comfyPage.canvasOps.dragAndDrop(dragStart, dragTarget)
await expect
.poll(async () => {
const draggedBox = await ksamplerNode.boundingBox()
return draggedBox ? Math.round(draggedBox.x) : null
})
.toBe(Math.round(checkpointBox.x))
})
})

View File

@@ -50,6 +50,12 @@ const snapToGrid = computed({
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
})
const alignNodesWhileDragging = computed({
get: () => settingStore.get('Comfy.Canvas.AlignNodesWhileDragging'),
set: (value) =>
settingStore.set('Comfy.Canvas.AlignNodesWhileDragging', value)
})
// CONNECTION LINKS settings
const linkShape = computed({
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
@@ -160,6 +166,11 @@ function openFullSettings() {
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
:tooltip="t('settings.pysssss_SnapToGrid.tooltip')"
/>
<FieldSwitch
v-model="alignNodesWhileDragging"
:label="t('settings.Comfy_Canvas_AlignNodesWhileDragging.name')"
:tooltip="t('settings.Comfy_Canvas_AlignNodesWhileDragging.tooltip')"
/>
</div>
</PropertiesAccordionItem>

View File

@@ -5007,6 +5007,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
this._drawConnectingLinks(ctx)
if (LiteGraph.vueNodesMode) {
this._drawVueDragAlignmentGuides(ctx)
}
} else {
this._drawOverlayLinks()
}
@@ -5111,7 +5114,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
if (!this.linkConnector.isConnecting) return
const hasDragGuides = layoutStore.vueDragSnapGuides.value.length > 0
if (!this.linkConnector.isConnecting && !hasDragGuides) return
octx.save()
@@ -5120,11 +5124,39 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.ds.toCanvasContext(octx)
this._drawConnectingLinks(octx)
if (this.linkConnector.isConnecting) {
this._drawConnectingLinks(octx)
}
this._drawVueDragAlignmentGuides(octx)
octx.restore()
}
private _drawVueDragAlignmentGuides(ctx: CanvasRenderingContext2D): void {
const guides = layoutStore.vueDragSnapGuides.value
if (!guides.length) return
const scale = this.ds.scale || 1
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1 / scale
ctx.strokeStyle = '#ff4d4f'
ctx.setLineDash([6 / scale, 4 / scale])
for (const guide of guides) {
if (guide.axis === 'vertical') {
ctx.moveTo(guide.coordinate, guide.start)
ctx.lineTo(guide.coordinate, guide.end)
} else {
ctx.moveTo(guide.start, guide.coordinate)
ctx.lineTo(guide.end, guide.coordinate)
}
}
ctx.stroke()
ctx.restore()
}
/** Get the target snap / highlight point in graph space */
private _getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy

View File

@@ -55,6 +55,10 @@
"name": "Show selection toolbox",
"tooltip": "Display a floating toolbar when nodes are selected, providing quick access to common actions."
},
"Comfy_Canvas_AlignNodesWhileDragging": {
"name": "Align nodes while dragging",
"tooltip": "When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together."
},
"Comfy_ConfirmClear": {
"name": "Require confirmation when clearing workflow"
},

View File

@@ -996,6 +996,16 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.10.5'
},
{
id: 'Comfy.Canvas.AlignNodesWhileDragging',
category: ['LiteGraph', 'Canvas', 'AlignNodesWhileDragging'],
name: 'Align nodes while dragging',
tooltip:
'When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.42.0'
},
{
id: 'LiteGraph.Reroute.SplineOffset',
name: 'Reroute spline offset',

View File

@@ -30,6 +30,7 @@ import type {
LinkSegmentLayout,
MoveNodeOperation,
MoveRerouteOperation,
NodeAlignmentGuide,
NodeBoundsUpdate,
NodeId,
NodeLayout,
@@ -144,6 +145,7 @@ class LayoutStoreImpl implements LayoutStore {
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
public vueDragSnapGuides = ref<NodeAlignmentGuide[]>([])
// Vue resizing state to prevent drag from activating during resize
public isResizingVueNodes = ref(false)

View File

@@ -32,6 +32,13 @@ export interface Bounds {
height: number
}
export interface NodeAlignmentGuide {
axis: 'horizontal' | 'vertical'
coordinate: number
start: number
end: number
}
export interface NodeBoundsUpdate {
nodeId: NodeId
bounds: Bounds

View File

@@ -14,6 +14,14 @@ export function isBoundsEqual(a: Bounds, b: Bounds): boolean {
)
}
export function translateBounds(bounds: Bounds, delta: Point): Bounds {
return {
...bounds,
x: bounds.x + delta.x,
y: bounds.y + delta.y
}
}
export function isSizeEqual(a: Size, b: Size): boolean {
return a.width === b.width && a.height === b.height
}

View File

@@ -24,11 +24,13 @@ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
const startDrag = vi.fn()
const handleDrag = vi.fn()
const endDrag = vi.fn()
const cancelDrag = vi.fn()
return {
useNodeDrag: () => ({
startDrag,
handleDrag,
endDrag
endDrag,
cancelDrag
})
}
})
@@ -93,6 +95,7 @@ const mockData = vi.hoisted(() => {
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const isResizingVueNodes = ref(false)
const vueDragSnapGuides = ref([])
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
@@ -100,6 +103,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => {
layoutStore: {
isDraggingVueNodes,
isResizingVueNodes,
vueDragSnapGuides,
getNodeLayoutRef,
setSource
}
@@ -136,6 +140,9 @@ describe('useNodePointerInteractions', () => {
vi.resetAllMocks()
selectedItemsState.items = []
setActivePinia(createTestingPinia())
layoutStore.isDraggingVueNodes.value = false
layoutStore.isResizingVueNodes.value = false
layoutStore.vueDragSnapGuides.value = []
})
it('should only start drag on left-click', async () => {
@@ -184,6 +191,7 @@ describe('useNodePointerInteractions', () => {
it('should handle drag termination via cancel and context menu', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { cancelDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
@@ -229,6 +237,7 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onContextmenu(contextMenuEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(cancelDrag).toHaveBeenCalledTimes(1)
})
it('should integrate with layout store dragging state', async () => {

View File

@@ -12,7 +12,7 @@ import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
export function useNodePointerInteractions(
nodeIdRef: MaybeRefOrGetter<string>
) {
const { startDrag, endDrag, handleDrag } = useNodeDrag()
const { startDrag, endDrag, handleDrag, cancelDrag } = useNodeDrag()
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
@@ -102,6 +102,7 @@ export function useNodePointerInteractions(
function cleanupDragState() {
layoutStore.isDraggingVueNodes.value = false
layoutStore.vueDragSnapGuides.value = []
}
function safeDragStart(event: PointerEvent, nodeId: string) {
@@ -124,6 +125,17 @@ export function useNodePointerInteractions(
}
}
function safeDragCancel() {
try {
cancelDrag()
} catch (error) {
console.error('Error during cancelDrag:', error)
} finally {
hasDraggingStarted = false
cleanupDragState()
}
}
function onPointerup(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
@@ -166,12 +178,15 @@ export function useNodePointerInteractions(
if (!layoutStore.isDraggingVueNodes.value) return
event.preventDefault()
// Simply cleanup state without calling endDrag to avoid synthetic event creation
cleanupDragState()
safeDragCancel()
}
// Cleanup on unmount to prevent resource leaks
onScopeDispose(() => {
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
safeDragCancel()
return
}
cleanupDragState()
})

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
describe('resolveNodeAlignmentSnap', () => {
const selectionBounds = {
x: 0,
y: 0,
width: 100,
height: 80
}
it('snaps matching edges within threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 0,
width: 100,
height: 80
}
],
delta: { x: 193, y: 0 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 200, y: 0 })
expect(result.guides).toContainEqual({
axis: 'vertical',
coordinate: 200,
start: 0,
end: 80
})
})
it('snaps matching centers within threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 0,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 0, y: 196 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 0, y: 200 })
expect(result.guides).toContainEqual({
axis: 'horizontal',
coordinate: 200,
start: 0,
end: 100
})
})
it('prefers the nearest candidate correction', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 0,
width: 100,
height: 80
},
{
x: 198,
y: 0,
width: 100,
height: 80
}
],
delta: { x: 193, y: 0 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 198, y: 0 })
expect(result.guides).toContainEqual({
axis: 'vertical',
coordinate: 198,
start: 0,
end: 80
})
})
it('does not snap when outside the zoom-adjusted threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 183, y: 0 },
zoomScale: 0.5
})
expect(result.delta).toEqual({ x: 183, y: 0 })
expect(result.guides).toEqual([])
})
it('tightens the canvas threshold as zoom increases', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 195, y: 0 },
zoomScale: 2
})
expect(result.delta).toEqual({ x: 195, y: 0 })
expect(result.guides).toEqual([])
})
})

View File

@@ -0,0 +1,215 @@
import type {
Bounds,
NodeAlignmentGuide,
Point
} from '@/renderer/core/layout/types'
import { translateBounds } from '@/renderer/core/layout/utils/geometry'
const DEFAULT_THRESHOLD_PX = 8
type HorizontalAnchor = 'bottom' | 'centerY' | 'top'
type VerticalAnchor = 'centerX' | 'left' | 'right'
export interface NodeAlignmentSnapResult {
delta: Point
guides: NodeAlignmentGuide[]
}
interface AxisMatch {
axis: 'horizontal' | 'vertical'
anchor: HorizontalAnchor | VerticalAnchor
candidateBounds: Bounds
correction: number
}
interface ResolveNodeAlignmentSnapOptions {
candidateBounds: Bounds[]
delta: Point
selectionBounds: Bounds
thresholdPx?: number
zoomScale: number
}
export function resolveNodeAlignmentSnap({
candidateBounds,
delta,
selectionBounds,
thresholdPx = DEFAULT_THRESHOLD_PX,
zoomScale
}: ResolveNodeAlignmentSnapOptions): NodeAlignmentSnapResult {
if (!candidateBounds.length || zoomScale <= 0) {
return { delta, guides: [] }
}
const threshold = thresholdPx / zoomScale
const translatedSelectionBounds = translateBounds(selectionBounds, delta)
const verticalMatch = findBestVerticalMatch(
translatedSelectionBounds,
candidateBounds,
threshold
)
const horizontalMatch = findBestHorizontalMatch(
translatedSelectionBounds,
candidateBounds,
threshold
)
const snappedDelta = {
x: delta.x + (verticalMatch?.correction ?? 0),
y: delta.y + (horizontalMatch?.correction ?? 0)
}
const snappedSelectionBounds = translateBounds(selectionBounds, snappedDelta)
const guides = [
verticalMatch &&
createVerticalGuide(
snappedSelectionBounds,
verticalMatch.candidateBounds
),
horizontalMatch &&
createHorizontalGuide(
snappedSelectionBounds,
horizontalMatch.candidateBounds
)
].filter((guide): guide is NodeAlignmentGuide => guide !== undefined)
return {
delta: snappedDelta,
guides
}
}
function findBestVerticalMatch(
selectionBounds: Bounds,
candidateBounds: Bounds[],
threshold: number
): AxisMatch | undefined {
return findBestMatch<VerticalAnchor>(
'vertical',
getVerticalAnchorValues(selectionBounds),
candidateBounds,
threshold,
getVerticalAnchorValues
)
}
function findBestHorizontalMatch(
selectionBounds: Bounds,
candidateBounds: Bounds[],
threshold: number
): AxisMatch | undefined {
return findBestMatch<HorizontalAnchor>(
'horizontal',
getHorizontalAnchorValues(selectionBounds),
candidateBounds,
threshold,
getHorizontalAnchorValues
)
}
function findBestMatch<TAnchor extends HorizontalAnchor | VerticalAnchor>(
axis: 'horizontal' | 'vertical',
selectionAnchors: Record<TAnchor, number>,
candidateBounds: Bounds[],
threshold: number,
getCandidateAnchors: (bounds: Bounds) => Record<TAnchor, number>
): AxisMatch | undefined {
let bestMatch: AxisMatch | undefined
for (const bounds of candidateBounds) {
const candidateAnchors = getCandidateAnchors(bounds)
for (const anchor of Object.keys(selectionAnchors) as TAnchor[]) {
const correction = candidateAnchors[anchor] - selectionAnchors[anchor]
if (Math.abs(correction) > threshold) {
continue
}
if (!bestMatch || Math.abs(correction) < Math.abs(bestMatch.correction)) {
bestMatch = {
axis,
anchor,
candidateBounds: bounds,
correction
}
}
}
}
return bestMatch
}
function getVerticalAnchorValues(
bounds: Bounds
): Record<VerticalAnchor, number> {
return {
left: bounds.x,
centerX: bounds.x + bounds.width * 0.5,
right: bounds.x + bounds.width
}
}
function getHorizontalAnchorValues(
bounds: Bounds
): Record<HorizontalAnchor, number> {
return {
top: bounds.y,
centerY: bounds.y + bounds.height * 0.5,
bottom: bounds.y + bounds.height
}
}
function createVerticalGuide(
selectionBounds: Bounds,
candidateBounds: Bounds
): NodeAlignmentGuide {
const candidateAnchors = getVerticalAnchorValues(candidateBounds)
const selectionAnchors = getVerticalAnchorValues(selectionBounds)
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
return {
axis: 'vertical',
coordinate,
start: Math.min(selectionBounds.y, candidateBounds.y),
end: Math.max(
selectionBounds.y + selectionBounds.height,
candidateBounds.y + candidateBounds.height
)
}
}
function createHorizontalGuide(
selectionBounds: Bounds,
candidateBounds: Bounds
): NodeAlignmentGuide {
const candidateAnchors = getHorizontalAnchorValues(candidateBounds)
const selectionAnchors = getHorizontalAnchorValues(selectionBounds)
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
return {
axis: 'horizontal',
coordinate,
start: Math.min(selectionBounds.x, candidateBounds.x),
end: Math.max(
selectionBounds.x + selectionBounds.width,
candidateBounds.x + candidateBounds.width
)
}
}
function getSharedAnchorValue<
TAnchor extends HorizontalAnchor | VerticalAnchor
>(
selectionAnchors: Record<TAnchor, number>,
candidateAnchors: Record<TAnchor, number>
): number {
const anchors = Object.keys(selectionAnchors) as TAnchor[]
for (const anchor of anchors) {
if (selectionAnchors[anchor] === candidateAnchors[anchor]) {
return selectionAnchors[anchor]
}
}
return selectionAnchors[anchors[0]]
}

View File

@@ -1,21 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { NodeLayout } from '@/renderer/core/layout/types'
import type * as VueUseModule from '@vueuse/core'
import type { Bounds, NodeLayout } from '@/renderer/core/layout/types'
function getStoredNodeBounds(
layout: Pick<NodeLayout, 'position' | 'size'>
): Bounds {
return {
x: layout.position.x,
y: layout.position.y,
width: layout.size.width,
height: layout.size.height
}
}
function boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
const testState = vi.hoisted(() => {
return {
selectedNodeIds: null as unknown as Ref<Set<string>>,
selectedItems: null as unknown as Ref<unknown[]>,
nodeLayouts: new Map<string, Pick<NodeLayout, 'position' | 'size'>>(),
canvas: {
setDirty: vi.fn()
},
settingValues: {
'Comfy.Canvas.AlignNodesWhileDragging': false
} as Record<string, boolean>,
mutationFns: {
setSource: vi.fn(),
moveNode: vi.fn(),
batchMoveNodes: vi.fn()
},
batchUpdateNodeBounds: vi.fn(),
vueDragSnapGuides: { value: [] as unknown[] } as Ref<unknown[]>,
nodeSnap: {
shouldSnap: vi.fn(() => false),
shouldSnap: vi.fn((event: PointerEvent) => event.shiftKey),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos)
},
cancelAnimationFrame: vi.fn(),
@@ -23,6 +52,17 @@ const testState = vi.hoisted(() => {
}
})
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<typeof VueUseModule>('@vueuse/core')
return {
...actual,
createSharedComposable: <TArgs extends unknown[], TResult>(
composable: (...args: TArgs) => TResult
) => composable
}
})
vi.mock('pinia', () => ({
storeToRefs: <T>(store: T) => store
}))
@@ -30,7 +70,8 @@ vi.mock('pinia', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
selectedNodeIds: testState.selectedNodeIds,
selectedItems: testState.selectedItems
selectedItems: testState.selectedItems,
canvas: testState.canvas
})
}))
@@ -42,10 +83,23 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getNodeLayoutRef: (nodeId: string) =>
ref(testState.nodeLayouts.get(nodeId) ?? null),
batchUpdateNodeBounds: testState.batchUpdateNodeBounds
queryNodesInBounds: (bounds: Bounds) =>
Array.from(testState.nodeLayouts.entries())
.filter(([, layout]) =>
boundsIntersect(getStoredNodeBounds(layout), bounds)
)
.map(([nodeId]) => nodeId),
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
vueDragSnapGuides: testState.vueDragSnapGuides
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => testState.settingValues[key]
})
}))
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
useNodeSnap: () => testState.nodeSnap
}))
@@ -58,6 +112,7 @@ vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
useTransformState: () => ({
camera: { z: 1 },
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x, y })
})
}))
@@ -66,6 +121,12 @@ vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: () => false
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
NODE_TITLE_HEIGHT: 30
}
}))
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
describe('useNodeDrag', () => {
@@ -73,12 +134,17 @@ describe('useNodeDrag', () => {
testState.selectedNodeIds = ref(new Set<string>())
testState.selectedItems = ref<unknown[]>([])
testState.nodeLayouts.clear()
testState.canvas.setDirty.mockReset()
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = false
testState.mutationFns.setSource.mockReset()
testState.mutationFns.moveNode.mockReset()
testState.mutationFns.batchMoveNodes.mockReset()
testState.batchUpdateNodeBounds.mockReset()
testState.vueDragSnapGuides.value = []
testState.nodeSnap.shouldSnap.mockReset()
testState.nodeSnap.shouldSnap.mockReturnValue(false)
testState.nodeSnap.shouldSnap.mockImplementation(
(event: PointerEvent) => event.shiftKey
)
testState.nodeSnap.applySnapToPosition.mockReset()
testState.nodeSnap.applySnapToPosition.mockImplementation(
(pos: { x: number; y: number }) => pos
@@ -178,6 +244,55 @@ describe('useNodeDrag', () => {
expect(testState.mutationFns.moveNode).not.toHaveBeenCalled()
})
it('uses the latest pointer event when moves arrive before the next frame', () => {
testState.selectedNodeIds.value = new Set(['1'])
testState.nodeLayouts.set('1', {
position: { x: 50, y: 80 },
size: { width: 180, height: 110 }
})
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'
)
handleDrag(
{
clientX: 45,
clientY: 60,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 90, y: 130 } }
])
})
it('cancels pending RAF and applies snap updates on endDrag', () => {
testState.selectedNodeIds.value = new Set(['1'])
testState.nodeLayouts.set('1', {
@@ -231,4 +346,294 @@ describe('useNodeDrag', () => {
}
])
})
it('snaps a dragged multi-selection to matching node edges', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['1', '2'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: 40, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('3', {
position: { x: 200, y: 40 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
testState.canvas.setDirty.mockClear()
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 193,
clientY: 0,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 200, y: 40 } },
{ nodeId: '2', position: { x: 240, y: 40 } }
])
expect(testState.vueDragSnapGuides.value).toContainEqual({
axis: 'vertical',
coordinate: 200,
start: 10,
end: 100
})
expect(testState.canvas.setDirty).toHaveBeenCalledWith(true)
})
it('excludes selected nodes from alignment candidates', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['1', '2'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: 60, y: 40 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 63,
clientY: 0,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 63, y: 40 } },
{ nodeId: '2', position: { x: 123, y: 40 } }
])
expect(testState.vueDragSnapGuides.value).toEqual([])
})
it('only snaps against nearby nodes returned by the spatial query', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['1'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: 200, y: 320 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 193,
clientY: 0,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 193, y: 40 } }
])
expect(testState.vueDragSnapGuides.value).toEqual([])
})
it('suppresses alignment snapping when grid snapping is active', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['1'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: 200, y: 40 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 193,
clientY: 0,
shiftKey: true,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 193, y: 40 } }
])
expect(testState.vueDragSnapGuides.value).toEqual([])
})
it('does not move an unrelated selection when dragging an unselected node', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['2'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: 200, y: 40 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 193,
clientY: 0,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 200, y: 40 } }
])
})
it('snaps unselected drags using only the dragged node bounds', () => {
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
testState.selectedNodeIds.value = new Set(['2', '3'])
testState.nodeLayouts.set('1', {
position: { x: 0, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('2', {
position: { x: -500, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('3', {
position: { x: 400, y: 40 },
size: { width: 100, height: 60 }
})
testState.nodeLayouts.set('4', {
position: { x: 200, y: 40 },
size: { width: 100, height: 60 }
})
const { startDrag, handleDrag } = useNodeDrag()
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => false)
target.setPointerCapture = vi.fn()
handleDrag(
{
clientX: 193,
clientY: 0,
target,
pointerId: 1
} as unknown as PointerEvent,
'1'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
{ nodeId: '1', position: { x: 200, y: 40 } }
])
expect(testState.vueDragSnapGuides.value).toContainEqual({
axis: 'vertical',
coordinate: 200,
start: 10,
end: 100
})
})
it('skips redraw when clearing already-empty snap guides', () => {
testState.selectedNodeIds.value = new Set(['1'])
testState.nodeLayouts.set('1', {
position: { x: 50, y: 80 },
size: { width: 180, height: 110 }
})
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'
)
testState.requestAnimationFrameCallback?.(0)
expect(testState.canvas.setDirty).not.toHaveBeenCalled()
})
})

View File

@@ -1,33 +1,45 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
Bounds,
NodeAlignmentGuide,
NodeBoundsUpdate,
NodeId,
Point
} from '@/renderer/core/layout/types'
import { translateBounds } from '@/renderer/core/layout/utils/geometry'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
import type { NodeAlignmentSnapResult } from './nodeAlignmentSnap'
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
const SNAP_SEARCH_RADIUS_PX = 96
function useNodeDragIndividual() {
const canvasStore = useCanvasStore()
const mutations = useLayoutMutations()
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
const { selectedNodeIds, selectedItems } = storeToRefs(canvasStore)
const settingStore = useSettingStore()
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
// Snap-to-grid functionality
const { shouldSnap, applySnapToPosition } = useNodeSnap()
const { shouldSnap: shouldSnapToGrid, applySnapToPosition } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
@@ -36,12 +48,15 @@ function useNodeDragIndividual() {
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
let pendingDragEvent: PointerEvent | null = null
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
// For groups: track the last applied canvas delta to compute frame delta
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
let draggedSelectionBounds: Bounds | null = null
let draggedSelectionNodeIds: Set<NodeId> | null = null
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
@@ -53,12 +68,15 @@ function useNodeDragIndividual() {
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
pendingDragEvent = null
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
const isDraggedNodeInSelection = selectedNodes.has(nodeId)
draggedSelectionNodeIds = isDraggedNodeInSelection
? new Set(selectedNodes)
: new Set([nodeId])
draggedSelectionBounds = getDraggedSelectionBounds(draggedSelectionNodeIds)
updateDragSnapGuides([])
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
@@ -94,23 +112,28 @@ function useNodeDragIndividual() {
return
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
const { target, pointerId } = event
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
// Delay capture to drag to allow for the Node cloning
target.setPointerCapture(pointerId)
}
pendingDragEvent = event
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
rafId = requestAnimationFrame(() => {
rafId = null
if (!dragStartPos || !dragStartMouse) return
if (!dragStartPos || !dragStartMouse || !pendingDragEvent) return
const dragEvent = pendingDragEvent
pendingDragEvent = null
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
x: dragEvent.clientX - dragStartMouse.x,
y: dragEvent.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
@@ -120,11 +143,15 @@ function useNodeDragIndividual() {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
const snappedCanvasDelta = maybeResolveAlignmentSnap(
dragEvent,
canvasDelta
)
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
x: dragStartPos.x + snappedCanvasDelta.x,
y: dragStartPos.y + snappedCanvasDelta.y
}
// Move drag updates in one transaction to avoid per-node notify fan-out.
@@ -140,8 +167,8 @@ function useNodeDragIndividual() {
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
x: startPos.x + snappedCanvasDelta.x,
y: startPos.y + snappedCanvasDelta.y
}
updates.push({ nodeId: otherNodeId, position: newOtherPosition })
}
@@ -153,8 +180,8 @@ function useNodeDragIndividual() {
// 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
x: snappedCanvasDelta.x - lastCanvasDelta.x,
y: snappedCanvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
@@ -162,13 +189,13 @@ function useNodeDragIndividual() {
}
}
lastCanvasDelta = canvasDelta
lastCanvasDelta = snappedCanvasDelta
})
}
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event) && nodeId) {
if (shouldSnapToGrid(event) && nodeId) {
const boundsUpdates: NodeBoundsUpdate[] = []
// Snap main node
@@ -228,26 +255,171 @@ function useNodeDragIndividual() {
}
}
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
cleanupDrag()
}
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
function cancelDrag() {
cleanupDrag()
}
return {
startDrag,
handleDrag,
endDrag
endDrag,
cancelDrag
}
function maybeResolveAlignmentSnap(
event: PointerEvent,
canvasDelta: Point
): Point {
if (!settingStore.get('Comfy.Canvas.AlignNodesWhileDragging')) {
updateDragSnapGuides([])
return canvasDelta
}
const isGridSnapActive = shouldSnapToGrid(event)
if (
isGridSnapActive ||
!draggedSelectionBounds ||
!draggedSelectionNodeIds
) {
updateDragSnapGuides([])
return canvasDelta
}
const translatedSelectionBounds = translateBounds(
draggedSelectionBounds,
canvasDelta
)
const searchRadius = SNAP_SEARCH_RADIUS_PX / transformState.camera.z
const candidateBounds = getNearbyAlignmentCandidateBounds(
translatedSelectionBounds,
draggedSelectionNodeIds,
searchRadius
)
const snapResult: NodeAlignmentSnapResult = resolveNodeAlignmentSnap({
selectionBounds: draggedSelectionBounds,
candidateBounds,
delta: canvasDelta,
zoomScale: transformState.camera.z
})
updateDragSnapGuides(snapResult.guides)
return snapResult.delta
}
function getDraggedSelectionBounds(nodeIds: Set<NodeId>): Bounds | null {
const bounds = Array.from(nodeIds)
.map((id) => layoutStore.getNodeLayoutRef(id).value)
.filter((layout): layout is NonNullable<typeof layout> => layout !== null)
.map(getRenderedNodeBounds)
return mergeBounds(bounds)
}
function getNearbyAlignmentCandidateBounds(
translatedSelectionBounds: Bounds,
selectedNodeSet: Set<NodeId>,
searchRadius: number
): Bounds[] {
const candidateIds = layoutStore.queryNodesInBounds(
expandBounds(translatedSelectionBounds, searchRadius)
)
const candidates: Bounds[] = []
for (const nodeId of candidateIds) {
if (selectedNodeSet.has(nodeId)) {
continue
}
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) {
continue
}
candidates.push(getRenderedNodeBounds(layout))
}
return candidates
}
function updateDragSnapGuides(guides: NodeAlignmentGuide[]) {
if (!guides.length && !layoutStore.vueDragSnapGuides.value.length) {
return
}
layoutStore.vueDragSnapGuides.value = guides
canvasStore.canvas?.setDirty(true)
}
function cleanupDrag() {
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
pendingDragEvent = null
selectedGroups = null
lastCanvasDelta = null
draggedSelectionBounds = null
draggedSelectionNodeIds = null
updateDragSnapGuides([])
stopShiftSync?.()
stopShiftSync = null
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
}
function getRenderedNodeBounds(layout: {
position: Point
size: { width: number; height: number }
}): Bounds {
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT || 0
return {
x: layout.position.x,
y: layout.position.y - titleHeight,
width: layout.size.width,
height: layout.size.height + titleHeight
}
}
function mergeBounds(boundsList: Bounds[]): Bounds | null {
const [firstBounds, ...remainingBounds] = boundsList
if (!firstBounds) {
return null
}
let left = firstBounds.x
let top = firstBounds.y
let right = firstBounds.x + firstBounds.width
let bottom = firstBounds.y + firstBounds.height
for (const bounds of remainingBounds) {
left = Math.min(left, bounds.x)
top = Math.min(top, bounds.y)
right = Math.max(right, bounds.x + bounds.width)
bottom = Math.max(bottom, bounds.y + bounds.height)
}
return {
x: left,
y: top,
width: right - left,
height: bottom - top
}
}
function expandBounds(bounds: Bounds, padding: number): Bounds {
return {
x: bounds.x - padding,
y: bounds.y - padding,
width: bounds.width + padding * 2,
height: bounds.height + padding * 2
}
}

View File

@@ -466,6 +466,7 @@ const zSettings = z.object({
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
'Comfy.Canvas.AlignNodesWhileDragging': z.boolean(),
'LiteGraph.Group.SelectChildrenOnClick': z.boolean()
})