Compare commits

...

9 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
woctordho
82556f02a9 fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#9332)
## Summary

Previously when I switch from nodes 1.0 to 2.0, positions and sizes of
nodes do not follow 'always snap to grid'. You can guess what a mess it
is for people relying on snap to grid to retain sanity. This PR fixes
it.

## Changes

In `ensureCorrectLayoutScale`, we call `snapPoint` after the position
and the size are updated.

We also need to ensure that the snapped size is larger than the minimal
size required by the content, so I've added 'ceil' mode to `snapPoint`,
and the patch is larger than I thought first.

I'd happily try out nodes 2.0 once this is addressed :)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9332-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nodes-1-0-to-2-0-3176d73d365081f5b6bcc035a8ffa648)
by [Unito](https://www.unito.io)
2026-03-13 11:40:51 -07:00
21 changed files with 1209 additions and 55 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

@@ -45,7 +45,8 @@ import { LiteGraph, SubgraphNode } from './litegraph'
import {
alignOutsideContainer,
alignToContainer,
createBounds
createBounds,
snapPoint
} from './measure'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
@@ -2594,7 +2595,18 @@ export class LGraph
// configure nodes afterwards so they can reach each other
for (const [id, nodeData] of nodeDataMap) {
this.getNodeById(id)?.configure(nodeData)
const node = this.getNodeById(id)
node?.configure(nodeData)
if (LiteGraph.alwaysSnapToGrid && node) {
const snapTo = this.getSnapToGridSize()
if (node.snapToGrid(snapTo)) {
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
// This reassignment triggers the pos setter to sync to the Vue layout store
node.pos = [node.pos[0], node.pos[1]]
}
snapPoint(node.size, snapTo, 'ceil')
}
}
}

View File

@@ -2959,6 +2959,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Enforce minimum size
const min = node.computeSize()
if (this._snapToGrid) {
// Previously newBounds.size is snapped with 'round'
// Now the minimum size is snapped with 'ceil' to avoid clipping
snapPoint(min, this._snapToGrid, 'ceil')
}
if (newBounds.width < min[0]) {
// If resizing from left, adjust position to maintain right edge
if (resizeDirection.includes('W')) {
@@ -5002,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()
}
@@ -5106,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()
@@ -5115,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

@@ -130,6 +130,34 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
expect(point3).toEqual([20, 20])
})
test('snapPoint correctly snaps points to grid using ceil', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'ceil')).toBe(true)
expect(point).toEqual([15, 20])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'ceil')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'ceil')).toBe(true)
expect(point3).toEqual([20, -10])
})
test('snapPoint correctly snaps points to grid using floor', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'floor')).toBe(true)
expect(point).toEqual([10, 15])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'floor')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'floor')).toBe(true)
expect(point3).toEqual([10, -20])
})
test('createBounds correctly creates bounding box', ({ expect }) => {
const objects = [
{ boundingRect: [0, 0, 10, 10] as Rect },

View File

@@ -351,11 +351,15 @@ export function createBounds(
* @returns `true` if snapTo is truthy, otherwise `false`
* @remarks `NaN` propagates through this function and does not affect return value.
*/
export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
export function snapPoint(
pos: Point | Rect,
snapTo: number,
method: 'round' | 'ceil' | 'floor' = 'round'
): boolean {
if (!snapTo) return false
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo)
pos[0] = snapTo * Math[method](pos[0] / snapTo)
pos[1] = snapTo * Math[method](pos[1] / snapTo)
return true
}

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

@@ -57,7 +57,7 @@ export function useNodeSnap() {
if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height]
if (snapPoint(sizeArray, gridSizeValue)) {
if (snapPoint(sizeArray, gridSizeValue, 'ceil')) {
return { width: sizeArray[0], height: sizeArray[1] }
}
return { ...size }

View File

@@ -1,4 +1,6 @@
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces'
import type { Point } from '@/renderer/core/layout/types'
import {
@@ -15,7 +17,7 @@ interface Positioned {
size: LGPoint
}
function unprojectPosSize(item: Positioned, anchor: Point) {
function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) {
const c = unprojectBounds(
{
x: item.pos[0],
@@ -30,6 +32,14 @@ function unprojectPosSize(item: Positioned, anchor: Point) {
item.pos[1] = c.y
item.size[0] = c.width
item.size[1] = c.height
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(item.pos, snapTo, 'round')
snapPoint(item.size, snapTo, 'ceil')
}
}
}
/**
@@ -60,6 +70,18 @@ export function ensureCorrectLayoutScale(
const anchor = getGraphRenderAnchor(graph)
const applySnap = (
pos: [number, number],
method: 'round' | 'ceil' | 'floor' = 'round'
) => {
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(pos, snapTo, method)
}
}
}
for (const node of graph.nodes) {
const c = unprojectBounds(
{
@@ -75,6 +97,9 @@ export function ensureCorrectLayoutScale(
node.pos[1] = c.y
node.size[0] = c.width
node.size[1] = c.height
applySnap(node.pos)
applySnap(node.size, 'ceil')
}
for (const reroute of graph.reroutes.values()) {
@@ -84,10 +109,11 @@ export function ensureCorrectLayoutScale(
RENDER_SCALE_FACTOR
)
reroute.pos = [p.x, p.y]
applySnap(reroute.pos)
}
for (const group of graph.groups) {
unprojectPosSize(group, anchor)
unprojectPosSize(group, anchor, graph)
}
if ('inputNode' in graph && 'outputNode' in graph) {
@@ -96,7 +122,7 @@ export function ensureCorrectLayoutScale(
graph.outputNode as SubgraphOutputNode
]) {
if (ioNode) {
unprojectPosSize(ioNode, anchor)
unprojectPosSize(ioNode, anchor, graph)
}
}
}

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()
})

View File

@@ -17,6 +17,7 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
@@ -1309,10 +1310,14 @@ export class ComfyApp {
console.error(error)
return
}
const snapTo = LiteGraph.alwaysSnapToGrid
? this.rootGraph.getSnapToGridSize()
: 0
forEachNode(this.rootGraph, (node) => {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
snapPoint(size, snapTo, 'ceil')
node.setSize(size)
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend