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)
This commit is contained in:
woctordho
2026-03-14 02:40:51 +08:00
committed by GitHub
parent e34548724d
commit 82556f02a9
7 changed files with 89 additions and 9 deletions

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')) {

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

@@ -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

@@ -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