Fix/vue nodes snap to grid (#5973)

## Summary

Enable node snap to grid in vue nodes mirroring the same behavior as
litegraph.

- Show node snap preview (semi transparent white box target behind node)
- Resize snap to grid
- Shift + drag / Auto snap 
- Multi select + group snap

## Changes

- **What**: useNodeSnap.ts useShifyKeySync.ts setups the core hooks into
both the vue node positioning/resizing system and the event forwarding
technique for communicating to litegraph.

## Review Focus

Both new composables and specifically the useNodeLayout modifications to
batch the mutations when snapping.
A key tradeoff/note is why we are using the useShifyKeySync.ts which
dispatches a new shift event to the canvas layer. This approach is the
cleaner / more declaritive method mimicking how other vue node ->
litegraph realtime events are passed.

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5973-Fix-vue-nodes-snap-to-grid-2866d73d365081c1a058d223c8c52576)
by [Unito](https://www.unito.io)
This commit is contained in:
Simula_r
2025-10-09 11:27:18 -07:00
committed by GitHub
parent 6b3a4d214b
commit 1455845a30
5 changed files with 345 additions and 44 deletions

View File

@@ -4696,7 +4696,9 @@ export class LGraphCanvas
// draw nodes
const { visible_nodes } = this
const drawSnapGuides = this.#snapToGrid && this.isDragging
const drawSnapGuides =
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const node of visible_nodes) {
ctx.save()
@@ -6074,7 +6076,9 @@ export class LGraphCanvas
ctx.save()
ctx.globalAlpha = 0.5 * this.editor_alpha
const drawSnapGuides = this.#snapToGrid && this.isDragging
const drawSnapGuides =
this.#snapToGrid &&
(this.isDragging || layoutStore.isDraggingVueNodes.value)
for (const group of groups) {
// out of the visible area

View File

@@ -2,6 +2,8 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
interface Size {
width: number
@@ -35,6 +37,12 @@ export function useNodeResize(
const resizeStartSize = ref<Size | null>(null)
const intrinsicMinSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -42,6 +50,9 @@ export function useNodeResize(
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
// Track shift key state and sync to canvas for snap preview
const stopShiftSync = trackShiftKey(event)
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
@@ -95,19 +106,26 @@ export function useNodeResize(
const scaledDy = dy / scale
// Apply constraints: only minimum size based on content, no maximum
const newWidth = Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
)
const newHeight = Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
const constrainedSize = {
width: Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
),
height: Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
}
// Apply snap-to-grid if shift is held or always snap is enabled
const finalSize = shouldSnap(moveEvent)
? applySnapToSize(constrainedSize)
: constrainedSize
// Get the node element to apply size directly
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
resizeCallback({ width: newWidth, height: newHeight }, nodeElement)
resizeCallback(finalSize, nodeElement)
}
}
@@ -118,6 +136,9 @@ export function useNodeResize(
resizeStartSize.value = null
intrinsicMinSize.value = null
// Stop tracking shift key state
stopShiftSync()
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()

View File

@@ -0,0 +1,73 @@
import { computed } from 'vue'
import { snapPoint } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Composable for node snap-to-grid functionality
*
* Provides reactive access to snap settings and utilities for applying
* snap-to-grid behavior to Vue nodes during drag and resize operations.
*/
export function useNodeSnap() {
const settingStore = useSettingStore()
// Reactive snap settings
const gridSize = computed(() => settingStore.get('Comfy.SnapToGrid.GridSize'))
const alwaysSnap = computed(() => settingStore.get('pysssss.SnapToGrid'))
/**
* Determines if snap-to-grid should be applied based on shift key and settings
* @param event - The pointer event to check for shift key
* @returns true if snapping should be applied
*/
function shouldSnap(event: PointerEvent): boolean {
return event.shiftKey || alwaysSnap.value
}
/**
* Applies snap-to-grid to a position
* @param position - Position object with x, y coordinates
* @returns The snapped position as a new object
*/
function applySnapToPosition(position: { x: number; y: number }): {
x: number
y: number
} {
const size = gridSize.value
if (!size) return { ...position }
const posArray: [number, number] = [position.x, position.y]
if (snapPoint(posArray, size)) {
return { x: posArray[0], y: posArray[1] }
}
return { ...position }
}
/**
* Applies snap-to-grid to a size (width/height)
* @param size - Size object with width, height
* @returns The snapped size as a new object
*/
function applySnapToSize(size: { width: number; height: number }): {
width: number
height: number
} {
const gridSizeValue = gridSize.value
if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height]
if (snapPoint(sizeArray, gridSizeValue)) {
return { width: sizeArray[0], height: sizeArray[1] }
}
return { ...size }
}
return {
gridSize,
alwaysSnap,
shouldSnap,
applySnapToPosition,
applySnapToSize
}
}

View File

@@ -0,0 +1,107 @@
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import { shallowRef } from 'vue'
import { app } from '@/scripts/app'
/**
* Composable for synchronizing shift key state from Vue nodes to LiteGraph canvas.
*
* Enables snap-to-grid preview rendering in LiteGraph during Vue node drag/resize operations
* by dispatching synthetic keyboard events to the canvas element.
*
* @returns Object containing trackShiftKey function for shift state synchronization lifecycle
*
* @example
* ```ts
* const { trackShiftKey } = useShiftKeySync()
*
* function startDrag(event: PointerEvent) {
* const stopTracking = trackShiftKey(event)
* // ... drag logic
* // Call stopTracking() on pointerup to cleanup listeners
* }
* ```
*/
export function useShiftKeySync() {
const shiftKeyState = shallowRef(false)
let canvasEl: HTMLCanvasElement | null = null
/**
* Synchronizes shift key state to LiteGraph canvas by dispatching synthetic keyboard events.
*
* Only dispatches events when shift state actually changes to minimize overhead.
* Canvas reference is lazily initialized on first sync.
*
* @param isShiftPressed - Current shift key state to synchronize
*/
function syncShiftState(isShiftPressed: boolean) {
if (isShiftPressed === shiftKeyState.value) return
// Lazy-initialize canvas reference on first use
if (!canvasEl) {
canvasEl = app.canvas?.canvas ?? null
if (!canvasEl) return // Canvas not ready yet
}
shiftKeyState.value = isShiftPressed
canvasEl.dispatchEvent(
new KeyboardEvent(isShiftPressed ? 'keydown' : 'keyup', {
key: 'Shift',
shiftKey: isShiftPressed,
bubbles: true
})
)
}
/**
* Tracks shift key state during drag/resize operations and synchronizes to canvas.
*
* Attaches window-level keyboard event listeners for the duration of the operation.
* Listeners are automatically cleaned up when the returned function is called.
*
* @param initialEvent - Initial pointer event containing shift key state at drag/resize start
* @returns Cleanup function that removes event listeners - must be called when operation ends
*
* @example
* ```ts
* function startDrag(event: PointerEvent) {
* const stopTracking = trackShiftKey(event)
*
* const handlePointerUp = () => {
* stopTracking() // Cleanup listeners
* }
* }
* ```
*/
function trackShiftKey(initialEvent: PointerEvent): () => void {
// Sync initial shift state
syncShiftState(initialEvent.shiftKey)
// Listen for shift key press/release during the operation
const handleKeyEvent = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
syncShiftState(e.shiftKey)
}
const stopKeydown = useEventListener(window, 'keydown', handleKeyEvent, {
passive: true
})
const stopKeyup = useEventListener(window, 'keyup', handleKeyEvent, {
passive: true
})
// Return cleanup function that stops both listeners
return () => {
stopKeydown()
stopKeyup()
}
}
// Cleanup on component unmount
tryOnScopeDispose(() => {
shiftKeyState.value = false
canvasEl = null
})
return { trackShiftKey }
}

View File

@@ -7,7 +7,9 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
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 { Point } from '@/renderer/core/layout/types'
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
/**
* Composable for individual Vue node components
@@ -21,6 +23,12 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Snap-to-grid functionality
const { shouldSnap, applySnapToPosition } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
// Get the customRef for this node (shared write access)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
@@ -50,6 +58,8 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
/**
* Start dragging the node
@@ -57,6 +67,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
function startDrag(event: PointerEvent) {
if (!layoutRef.value || !transformState) return
// Track shift key state and sync to canvas for snap preview
stopShiftSync = trackShiftKey(event)
isDragging.value = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
@@ -100,42 +113,54 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
rafId = requestAnimationFrame(() => {
rafId = null
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
if (!dragStartPos || !dragStartMouse || !transformState) return
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [otherNodeId, startPos] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
})
}
/**
@@ -144,11 +169,82 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
function endDrag(event: PointerEvent) {
if (!isDragging.value) return
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event)) {
const boundsUpdates: NodeBoundsUpdate[] = []
// Snap main node
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (currentLayout) {
const currentPos = currentLayout.position
const snappedPos = applySnapToPosition({ ...currentPos })
// Only add update if position actually changed
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
boundsUpdates.push({
nodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: currentLayout.size.width,
height: currentLayout.size.height
}
})
}
}
// Also snap other selected nodes
// Capture all positions at the start to ensure consistent state
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
if (nodeLayout) {
const currentPos = { ...nodeLayout.position }
const snappedPos = applySnapToPosition(currentPos)
// Only add update if position actually changed
if (
snappedPos.x !== currentPos.x ||
snappedPos.y !== currentPos.y
) {
boundsUpdates.push({
nodeId: otherNodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: nodeLayout.size.width,
height: nodeLayout.size.height
}
})
}
}
}
}
// Apply all snap updates in a single batched transaction
if (boundsUpdates.length > 0) {
layoutStore.batchUpdateNodeBounds(boundsUpdates)
}
}
isDragging.value = false
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
// Release pointer
if (!(event.target instanceof HTMLElement)) return
event.target.releasePointerCapture(event.pointerId)