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 { import {
alignOutsideContainer, alignOutsideContainer,
alignToContainer, alignToContainer,
createBounds createBounds,
snapPoint
} from './measure' } from './measure'
import { SubgraphInput } from './subgraph/SubgraphInput' import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode' import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
@@ -2594,7 +2595,18 @@ export class LGraph
// configure nodes afterwards so they can reach each other // configure nodes afterwards so they can reach each other
for (const [id, nodeData] of nodeDataMap) { 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 // Enforce minimum size
const min = node.computeSize() 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 (newBounds.width < min[0]) {
// If resizing from left, adjust position to maintain right edge // If resizing from left, adjust position to maintain right edge
if (resizeDirection.includes('W')) { if (resizeDirection.includes('W')) {

View File

@@ -130,6 +130,34 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
expect(point3).toEqual([20, 20]) 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 }) => { test('createBounds correctly creates bounding box', ({ expect }) => {
const objects = [ const objects = [
{ boundingRect: [0, 0, 10, 10] as Rect }, { boundingRect: [0, 0, 10, 10] as Rect },

View File

@@ -351,11 +351,15 @@ export function createBounds(
* @returns `true` if snapTo is truthy, otherwise `false` * @returns `true` if snapTo is truthy, otherwise `false`
* @remarks `NaN` propagates through this function and does not affect return value. * @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 if (!snapTo) return false
pos[0] = snapTo * Math.round(pos[0] / snapTo) pos[0] = snapTo * Math[method](pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo) pos[1] = snapTo * Math[method](pos[1] / snapTo)
return true return true
} }

View File

@@ -57,7 +57,7 @@ export function useNodeSnap() {
if (!gridSizeValue) return { ...size } if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height] 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 { width: sizeArray[0], height: sizeArray[1] }
} }
return { ...size } return { ...size }

View File

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

View File

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