diff --git a/browser_tests/tests/vueNodes/interactions/node/resize.spec.ts b/browser_tests/tests/vueNodes/interactions/node/resize.spec.ts new file mode 100644 index 0000000000..7ea5bf965b --- /dev/null +++ b/browser_tests/tests/vueNodes/interactions/node/resize.spec.ts @@ -0,0 +1,54 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../../fixtures/ComfyPage' + +test.describe('Vue Node Resizing', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + }) + + test('should resize node without position drift after selecting', async ({ + comfyPage + }) => { + // Get a Vue node fixture + const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint') + const initialBox = await node.boundingBox() + if (!initialBox) throw new Error('Node bounding box not found') + + // Select the node first (this was causing the bug) + await node.header.click() + await comfyPage.page.waitForTimeout(100) // Brief pause after selection + + // Get position after selection + const selectedBox = await node.boundingBox() + if (!selectedBox) + throw new Error('Node bounding box not found after select') + + // Verify position unchanged after selection + expect(selectedBox.x).toBeCloseTo(initialBox.x, 1) + expect(selectedBox.y).toBeCloseTo(initialBox.y, 1) + + // Now resize from bottom-right corner + const resizeStartX = selectedBox.x + selectedBox.width - 5 + const resizeStartY = selectedBox.y + selectedBox.height - 5 + + await comfyPage.page.mouse.move(resizeStartX, resizeStartY) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30) + await comfyPage.page.mouse.up() + + // Get final position and size + const finalBox = await node.boundingBox() + if (!finalBox) throw new Error('Node bounding box not found after resize') + + // Position should NOT have changed (the bug was position drift) + expect(finalBox.x).toBeCloseTo(initialBox.x, 1) + expect(finalBox.y).toBeCloseTo(initialBox.y, 1) + + // Size should have increased + expect(finalBox.width).toBeGreaterThan(initialBox.width) + expect(finalBox.height).toBeGreaterThan(initialBox.height) + }) +}) diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index aeb21f32bb..b6e139e6bc 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -138,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore { // Vue dragging state for selection toolbox (public ref for direct mutation) public isDraggingVueNodes = ref(false) + // Vue resizing state to prevent drag from activating during resize + public isResizingVueNodes = ref(false) constructor() { // Initialize Yjs data structures diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 72f1e6c495..109d0c5629 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -117,17 +117,14 @@ - - + +
@@ -171,7 +168,6 @@ import { } from '@/utils/graphTraversalUtil' import { cn } from '@/utils/tailwindUtil' -import type { ResizeHandleDirection } from '../interactions/resize/resizeMath' import { useNodeResize } from '../interactions/resize/useNodeResize' import LivePreview from './LivePreview.vue' import NodeContent from './NodeContent.vue' @@ -263,7 +259,7 @@ onErrorCaptured((error) => { return false // Prevent error propagation }) -const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id) +const { position, size, zIndex } = useNodeLayout(() => nodeData.id) const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id) const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers const { startDrag } = useNodeDrag() @@ -314,41 +310,6 @@ onMounted(() => { const baseResizeHandleClasses = 'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40' -const POSITION_EPSILON = 0.01 - -type CornerResizeHandle = { - id: string - direction: ResizeHandleDirection - classes: string - ariaLabel: string -} - -const cornerResizeHandles: CornerResizeHandle[] = [ - { - id: 'se', - direction: { horizontal: 'right', vertical: 'bottom' }, - classes: 'right-0 bottom-0 cursor-se-resize', - ariaLabel: t('g.resizeFromBottomRight') - }, - { - id: 'ne', - direction: { horizontal: 'right', vertical: 'top' }, - classes: 'right-0 top-0 cursor-ne-resize', - ariaLabel: t('g.resizeFromTopRight') - }, - { - id: 'sw', - direction: { horizontal: 'left', vertical: 'bottom' }, - classes: 'left-0 bottom-0 cursor-sw-resize', - ariaLabel: t('g.resizeFromBottomLeft') - }, - { - id: 'nw', - direction: { horizontal: 'left', vertical: 'top' }, - classes: 'left-0 top-0 cursor-nw-resize', - ariaLabel: t('g.resizeFromTopLeft') - } -] const MIN_NODE_WIDTH = 225 @@ -361,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => { // Apply size directly to DOM element - ResizeObserver will pick this up element.style.setProperty('--node-width', `${clampedWidth}px`) element.style.setProperty('--node-height', `${result.size.height}px`) - - const currentPosition = position.value - const deltaX = Math.abs(result.position.x - currentPosition.x) - const deltaY = Math.abs(result.position.y - currentPosition.y) - - if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) { - moveNodeTo(result.position) - } }) -const handleResizePointerDown = (direction: ResizeHandleDirection) => { - return (event: PointerEvent) => { - if (nodeData.flags?.pinned) return - - startResize(event, direction, { ...position.value }) - } +const handleResizePointerDown = (event: PointerEvent) => { + if (nodeData.flags?.pinned) return + startResize(event) } watch(isCollapsed, (collapsed) => { diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts index 5108a781b3..eb96ff8c84 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.test.ts @@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => { vi.mock('@/renderer/core/layout/store/layoutStore', () => { const isDraggingVueNodes = ref(false) + const isResizingVueNodes = ref(false) const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout) const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef) const setSource = vi.fn() return { layoutStore: { isDraggingVueNodes, + isResizingVueNodes, getNodeLayoutRef, setSource } diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index 33369bc064..26d2b8a4b0 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -63,6 +63,9 @@ export function useNodePointerInteractions( function onPointermove(event: PointerEvent) { if (forwardMiddlePointerIfNeeded(event)) return + // Don't activate drag while resizing + if (layoutStore.isResizingVueNodes.value) return + const nodeId = toValue(nodeIdRef) if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) { diff --git a/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts b/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts deleted file mode 100644 index a837d59c58..0000000000 --- a/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Point, Size } from '@/renderer/core/layout/types' - -export type ResizeHandleDirection = { - horizontal: 'left' | 'right' - vertical: 'top' | 'bottom' -} - -function applyHandleDelta( - startSize: Size, - delta: Point, - handle: ResizeHandleDirection -): Size { - const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1 - const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1 - - return { - width: startSize.width + delta.x * horizontalMultiplier, - height: startSize.height + delta.y * verticalMultiplier - } -} - -function computeAdjustedPosition( - startPosition: Point, - startSize: Size, - nextSize: Size, - handle: ResizeHandleDirection -): Point { - const widthDelta = startSize.width - nextSize.width - const heightDelta = startSize.height - nextSize.height - - return { - x: - handle.horizontal === 'left' - ? startPosition.x + widthDelta - : startPosition.x, - y: - handle.vertical === 'top' - ? startPosition.y + heightDelta - : startPosition.y - } -} - -/** - * Computes the resulting size and position of a node given pointer movement - * and handle orientation. - */ -export function computeResizeOutcome({ - startSize, - startPosition, - delta, - handle, - snapFn -}: { - startSize: Size - startPosition: Point - delta: Point - handle: ResizeHandleDirection - snapFn?: (size: Size) => Size -}): { size: Size; position: Point } { - const resized = applyHandleDelta(startSize, delta, handle) - const snapped = snapFn?.(resized) ?? resized - const position = computeAdjustedPosition( - startPosition, - startSize, - snapped, - handle - ) - - return { - size: snapped, - position - } -} - -export function createResizeSession(config: { - startSize: Size - startPosition: Point - handle: ResizeHandleDirection -}) { - const startSize = { ...config.startSize } - const startPosition = { ...config.startPosition } - const handle = config.handle - - return (delta: Point, snapFn?: (size: Size) => Size) => - computeResizeOutcome({ - startSize, - startPosition, - handle, - delta, - snapFn - }) -} - -export function toCanvasDelta( - startPointer: Point, - currentPointer: Point, - scale: number -): Point { - const safeScale = scale === 0 ? 1 : scale - return { - x: (currentPointer.x - startPointer.x) / safeScale, - y: (currentPointer.y - startPointer.y) / safeScale - } -} diff --git a/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts index 4d63e00ab2..75749e8121 100644 --- a/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts +++ b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts @@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core' import { ref } from 'vue' import type { Point, Size } from '@/renderer/core/layout/types' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap' import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync' - -import type { ResizeHandleDirection } from './resizeMath' -import { createResizeSession, toCanvasDelta } from './resizeMath' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' interface ResizeCallbackPayload { size: Size - position: Point } /** - * Composable for node resizing functionality + * Composable for node resizing functionality (bottom-right corner only) * * Provides resize handle interaction that integrates with the layout system. * Handles pointer capture, coordinate calculations, and size constraints. @@ -27,16 +24,7 @@ export function useNodeResize( const isResizing = ref(false) const resizeStartPointer = ref(null) - const resizeSession = ref< - | (( - delta: Point, - snapFn?: (size: Size) => Size - ) => { - size: Size - position: Point - }) - | null - >(null) + const resizeStartSize = ref(null) // Snap-to-grid functionality const { shouldSnap, applySnapToSize } = useNodeSnap() @@ -44,11 +32,7 @@ export function useNodeResize( // Shift key sync for LiteGraph canvas preview const { trackShiftKey } = useShiftKeySync() - const startResize = ( - event: PointerEvent, - handle: ResizeHandleDirection, - startPosition: Point - ) => { + const startResize = (event: PointerEvent) => { event.preventDefault() event.stopPropagation() @@ -72,47 +56,49 @@ export function useNodeResize( // Capture pointer to ensure we get all move/up events target.setPointerCapture(event.pointerId) + // Mark as resizing to prevent drag from activating + layoutStore.isResizingVueNodes.value = true isResizing.value = true resizeStartPointer.value = { x: event.clientX, y: event.clientY } - resizeSession.value = createResizeSession({ - startSize, - startPosition: { ...startPosition }, - handle - }) + resizeStartSize.value = startSize const handlePointerMove = (moveEvent: PointerEvent) => { if ( !isResizing.value || !resizeStartPointer.value || - !resizeSession.value - ) + !resizeStartSize.value + ) { return + } + + const scale = transformState.camera.z + const deltaX = + (moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1) + const deltaY = + (moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1) - const startPointer = resizeStartPointer.value - const session = resizeSession.value + let newSize: Size = { + width: resizeStartSize.value.width + deltaX, + height: resizeStartSize.value.height + deltaY + } - const delta = toCanvasDelta( - startPointer, - { x: moveEvent.clientX, y: moveEvent.clientY }, - transformState.camera.z - ) + // Apply snap if shift is held + if (shouldSnap(moveEvent)) { + newSize = applySnapToSize(newSize) + } const nodeElement = target.closest('[data-node-id]') if (nodeElement instanceof HTMLElement) { - const outcome = session( - delta, - shouldSnap(moveEvent) ? applySnapToSize : undefined - ) - - resizeCallback(outcome, nodeElement) + resizeCallback({ size: newSize }, nodeElement) } } const handlePointerUp = (upEvent: PointerEvent) => { if (isResizing.value) { isResizing.value = false + layoutStore.isResizingVueNodes.value = false resizeStartPointer.value = null - resizeSession.value = null + resizeStartSize.value = null // Stop tracking shift key state stopShiftSync() diff --git a/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts deleted file mode 100644 index d954e59b48..0000000000 --- a/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - computeResizeOutcome, - createResizeSession, - toCanvasDelta -} from '@/renderer/extensions/vueNodes/interactions/resize/resizeMath' - -describe('nodeResizeMath', () => { - const startSize = { width: 200, height: 120 } - const startPosition = { x: 80, y: 160 } - - it('computes resize from bottom-right corner without moving position', () => { - const outcome = computeResizeOutcome({ - startSize, - startPosition, - delta: { x: 40, y: 20 }, - handle: { horizontal: 'right', vertical: 'bottom' } - }) - - expect(outcome.size).toEqual({ width: 240, height: 140 }) - expect(outcome.position).toEqual(startPosition) - }) - - it('computes resize from top-left corner adjusting position', () => { - const outcome = computeResizeOutcome({ - startSize, - startPosition, - delta: { x: -30, y: -20 }, - handle: { horizontal: 'left', vertical: 'top' } - }) - - expect(outcome.size).toEqual({ width: 230, height: 140 }) - expect(outcome.position).toEqual({ x: 50, y: 140 }) - }) - - it('supports reusable resize sessions with snapping', () => { - const session = createResizeSession({ - startSize, - startPosition, - handle: { horizontal: 'right', vertical: 'bottom' } - }) - - const snapFn = vi.fn((size: typeof startSize) => ({ - width: Math.round(size.width / 25) * 25, - height: Math.round(size.height / 25) * 25 - })) - - const applied = session({ x: 13, y: 17 }, snapFn) - - expect(applied.size).toEqual({ width: 225, height: 125 }) - expect(applied.position).toEqual(startPosition) - expect(snapFn).toHaveBeenCalled() - }) - - it('converts screen delta to canvas delta using scale', () => { - const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 2) - - expect(delta).toEqual({ x: 50, y: 30 }) - }) - - describe('edge cases', () => { - it('handles zero scale by using fallback scale of 1', () => { - const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 0) - - expect(delta).toEqual({ x: 100, y: 60 }) - }) - - it('handles negative deltas when resizing from right/bottom', () => { - const outcome = computeResizeOutcome({ - startSize, - startPosition, - delta: { x: -50, y: -30 }, - handle: { horizontal: 'right', vertical: 'bottom' } - }) - - expect(outcome.size).toEqual({ width: 150, height: 90 }) - expect(outcome.position).toEqual(startPosition) - }) - - it('handles very large deltas without overflow', () => { - const outcome = computeResizeOutcome({ - startSize, - startPosition, - delta: { x: 10000, y: 10000 }, - handle: { horizontal: 'right', vertical: 'bottom' } - }) - - expect(outcome.size.width).toBe(10200) - expect(outcome.size.height).toBe(10120) - expect(outcome.position).toEqual(startPosition) - }) - }) -})