mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
73
src/renderer/extensions/vueNodes/composables/useNodeSnap.ts
Normal file
73
src/renderer/extensions/vueNodes/composables/useNodeSnap.ts
Normal 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
|
||||
}
|
||||
}
|
||||
107
src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts
Normal file
107
src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user