diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index ea58b8bb4..29da5df92 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -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 diff --git a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts index a216646e2..14e4e2844 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts @@ -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(null) const intrinsicMinSize = ref(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() diff --git a/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts b/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts new file mode 100644 index 000000000..4b07621be --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodeSnap.ts @@ -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 + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts b/src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts new file mode 100644 index 000000000..7aebe2177 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useShiftKeySync.ts @@ -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 } +} diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 2b6881eae..87970604d 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -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) { // 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) { let dragStartPos: Point | null = null let dragStartMouse: Point | null = null let otherSelectedNodesStartPositions: Map | 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) { 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) { 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) { 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)