From c52f48af4566bfc87cd545d4a7e86915f45ab501 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 12 Feb 2026 22:28:21 -0500 Subject: [PATCH] feat(vueNodes): support resizing from all four corners (#8845) ## Summary (Not sure we need this, and I don't know the reason why we only have one cornor support previously, but it is requested by QA reporting in Notion) Add resize handles at all four corners (NW, NE, SW, SE) of Vue nodes, matching litegraph's multi-corner resize behavior. Vue nodes previously only supported resizing from the bottom-right (SE) corner. This adds handles at all four corners with direction-aware delta math, snap-to-grid support, and minimum size enforcement that keeps the opposite corner anchored. The content-driven minimum height is measured from the DOM at resize start to prevent the node from sliding when dragged past its minimum size. ## Screenshots (if applicable) https://github.com/user-attachments/assets/c9d30d93-8243-4c44-a417-5ca6e9978b3b --- .../vueNodes/components/LGraphNode.vue | 77 +++-- .../interactions/resize/resizeHandleConfig.ts | 45 +++ .../interactions/resize/useNodeResize.test.ts | 273 ++++++++++++++++++ .../interactions/resize/useNodeResize.ts | 125 +++++++- 4 files changed, 478 insertions(+), 42 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig.ts create mode 100644 src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.test.ts diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 55bc75ac42..2a474defa4 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -162,33 +162,40 @@ - -
- +
- - -
+ + + +
+ @@ -249,6 +256,10 @@ import { } from '@/utils/graphTraversalUtil' import { cn } from '@/utils/tailwindUtil' +import type { CompassCorners } from '@/lib/litegraph/src/interfaces' +import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' + +import { RESIZE_HANDLES } from '../interactions/resize/resizeHandleConfig' import { useNodeResize } from '../interactions/resize/useNodeResize' import LivePreview from './LivePreview.vue' import NodeContent from './NodeContent.vue' @@ -423,6 +434,7 @@ const baseResizeHandleClasses = 'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40' const MIN_NODE_WIDTH = 225 +const mutations = useLayoutMutations() const { startResize } = useNodeResize((result, element) => { if (isCollapsed.value) return @@ -433,14 +445,23 @@ 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`) + + // Update position for non-SE corner resizing + if (result.position) { + mutations.setSource(LayoutSource.Vue) + mutations.moveNode(nodeData.id, result.position) + } }) -const handleResizePointerDown = (event: PointerEvent) => { +const handleResizePointerDown = ( + event: PointerEvent, + corner: CompassCorners +) => { if (event.button !== 0) return if (!shouldHandleNodePointerEvents.value) return if (nodeData.flags?.pinned) return if (nodeData.resizable === false) return - startResize(event) + startResize(event, corner) } watch(isCollapsed, (collapsed) => { diff --git a/src/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig.ts b/src/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig.ts new file mode 100644 index 0000000000..62b0fbb095 --- /dev/null +++ b/src/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig.ts @@ -0,0 +1,45 @@ +import type { CompassCorners } from '@/lib/litegraph/src/interfaces' + +interface ResizeHandle { + corner: CompassCorners + positionClasses: string + cursorClass: string + i18nKey: string + svgPositionClasses: string + svgTransform: string +} + +export const RESIZE_HANDLES: ResizeHandle[] = [ + { + corner: 'SE', + positionClasses: '-right-1 -bottom-1', + cursorClass: 'cursor-se-resize', + i18nKey: 'g.resizeFromBottomRight', + svgPositionClasses: 'top-1 left-1', + svgTransform: '' + }, + { + corner: 'NE', + positionClasses: '-right-1 -top-1', + cursorClass: 'cursor-ne-resize', + i18nKey: 'g.resizeFromTopRight', + svgPositionClasses: 'bottom-1 left-1', + svgTransform: 'scaleY(-1)' + }, + { + corner: 'SW', + positionClasses: '-left-1 -bottom-1', + cursorClass: 'cursor-sw-resize', + i18nKey: 'g.resizeFromBottomLeft', + svgPositionClasses: 'top-1 right-1', + svgTransform: 'scaleX(-1)' + }, + { + corner: 'NW', + positionClasses: '-left-1 -top-1', + cursorClass: 'cursor-nw-resize', + i18nKey: 'g.resizeFromTopLeft', + svgPositionClasses: 'bottom-1 right-1', + svgTransform: 'scale(-1, -1)' + } +] diff --git a/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.test.ts b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.test.ts new file mode 100644 index 0000000000..1b99ae8fa1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.test.ts @@ -0,0 +1,273 @@ +import type { MockInstance } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { CompassCorners } from '@/lib/litegraph/src/interfaces' + +import type { ResizeCallbackPayload } from './useNodeResize' + +type ResizeCallback = ( + payload: ResizeCallbackPayload, + element: HTMLElement +) => void + +// Capture pointermove/pointerup handlers registered via useEventListener +const eventHandlers = vi.hoisted(() => ({ + pointermove: null as ((e: PointerEvent) => void) | null, + pointerup: null as ((e: PointerEvent) => void) | null +})) + +vi.mock('@vueuse/core', () => ({ + useEventListener: vi.fn( + (eventName: string, handler: (...args: unknown[]) => void) => { + if (eventName === 'pointermove' || eventName === 'pointerup') { + eventHandlers[eventName] = handler as (e: PointerEvent) => void + } + return vi.fn() + } + ) +})) + +vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({ + useTransformState: () => ({ + camera: { x: 0, y: 0, z: 1 } + }) +})) + +vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({ + useNodeSnap: () => ({ + shouldSnap: vi.fn(() => false), + applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos), + applySnapToSize: vi.fn((size: { width: number; height: number }) => size) + }) +})) + +vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({ + useShiftKeySync: () => ({ + trackShiftKey: vi.fn(() => vi.fn()) + }) +})) + +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + isResizingVueNodes: { value: false }, + getNodeLayoutRef: vi.fn(() => ({ + value: { + position: { x: 100, y: 200 }, + size: { width: 300, height: 400 } + } + })) + } +})) + +function createMockNodeElement( + width = 300, + height = 400, + minContentHeight = 150 +): HTMLElement { + const element = document.createElement('div') + element.setAttribute('data-node-id', 'test-node') + element.style.setProperty('min-width', '225px') + element.getBoundingClientRect = () => { + // When --node-height is '0px', return the content-driven minimum height + const nodeHeight = element.style.getPropertyValue('--node-height') + const h = nodeHeight === '0px' ? minContentHeight : height + return { + width, + height: h, + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: h, + toJSON: () => {} + } as DOMRect + } + return element +} + +function createMockHandle(nodeElement: HTMLElement): HTMLElement { + const handle = document.createElement('div') + nodeElement.appendChild(handle) + handle.setPointerCapture = vi.fn() + handle.releasePointerCapture = vi.fn() + return handle +} + +function createPointerEvent( + type: string, + overrides: Partial = {} +): PointerEvent { + return { + type, + clientX: 0, + clientY: 0, + pointerId: 1, + shiftKey: false, + currentTarget: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides + } as unknown as PointerEvent +} + +function startResizeAt( + startResize: (event: PointerEvent, corner: CompassCorners) => void, + handle: HTMLElement, + corner: CompassCorners, + clientX = 500, + clientY = 500 +) { + const downEvent = createPointerEvent('pointerdown', { + currentTarget: handle, + clientX, + clientY + } as Partial) + startResize(downEvent, corner) +} + +function simulateMove( + deltaX: number, + deltaY: number, + startX = 500, + startY = 500 +) { + const moveEvent = createPointerEvent('pointermove', { + clientX: startX + deltaX, + clientY: startY + deltaY + }) + eventHandlers.pointermove?.(moveEvent) +} + +describe('useNodeResize', () => { + let callback: ResizeCallback & MockInstance + let nodeElement: HTMLElement + let handle: HTMLElement + + beforeEach(async () => { + vi.clearAllMocks() + eventHandlers.pointermove = null + eventHandlers.pointerup = null + + callback = vi.fn() + nodeElement = createMockNodeElement() + handle = createMockHandle(nodeElement) + + // Need fresh import after mocks are set up + const { useNodeResize } = await import('./useNodeResize') + const { startResize } = useNodeResize(callback) + + // Store startResize for access in tests + ;(globalThis as Record).__testStartResize = startResize + }) + + function getStartResize() { + return (globalThis as Record).__testStartResize as ( + event: PointerEvent, + corner: CompassCorners + ) => void + } + + describe('SE corner (default)', () => { + it('increases size when dragging right and down', () => { + startResizeAt(getStartResize(), handle, 'SE') + simulateMove(50, 30) + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + size: { width: 350, height: 430 } + }), + nodeElement + ) + }) + + it('does not include position in payload', () => { + startResizeAt(getStartResize(), handle, 'SE') + simulateMove(50, 30) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.position).toBeUndefined() + }) + + it('clamps width to minimum', () => { + startResizeAt(getStartResize(), handle, 'SE') + simulateMove(-200, 0) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size.width).toBe(225) + }) + }) + + describe('NE corner', () => { + it('increases width right, decreases height upward, shifts y position', () => { + startResizeAt(getStartResize(), handle, 'NE') + simulateMove(50, -30) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size).toEqual({ width: 350, height: 430 }) + expect(payload.position).toEqual({ x: 100, y: 170 }) + }) + + it('clamps height to content minimum and fixes bottom edge', () => { + startResizeAt(getStartResize(), handle, 'NE') + simulateMove(0, 500) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + // minContentHeight = 150, so height clamps to 150 + expect(payload.size.height).toBe(150) + // y = startY + startHeight - minContentHeight = 200 + 400 - 150 = 450 + expect(payload.position!.y).toBe(450) + }) + }) + + describe('SW corner', () => { + it('decreases width leftward, increases height downward, shifts x position', () => { + startResizeAt(getStartResize(), handle, 'SW') + simulateMove(-50, 30) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size).toEqual({ width: 350, height: 430 }) + expect(payload.position).toEqual({ x: 50, y: 200 }) + }) + + it('clamps width to minimum and fixes right edge', () => { + startResizeAt(getStartResize(), handle, 'SW') + simulateMove(200, 0) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size.width).toBe(225) + expect(payload.position!.x).toBe(175) + }) + }) + + describe('NW corner', () => { + it('decreases width leftward, decreases height upward, shifts both x and y', () => { + startResizeAt(getStartResize(), handle, 'NW') + simulateMove(-50, -30) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size).toEqual({ width: 350, height: 430 }) + expect(payload.position).toEqual({ x: 50, y: 170 }) + }) + + it('clamps width to minimum and fixes right edge', () => { + startResizeAt(getStartResize(), handle, 'NW') + simulateMove(200, 0) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + expect(payload.size.width).toBe(225) + expect(payload.position!.x).toBe(175) + }) + + it('clamps height to content minimum and fixes bottom edge', () => { + startResizeAt(getStartResize(), handle, 'NW') + simulateMove(0, 500) + + const payload: ResizeCallbackPayload = callback.mock.calls[0][0] + // minContentHeight = 150, so height clamps to 150 + expect(payload.size.height).toBe(150) + // y = startY + startHeight - minContentHeight = 200 + 400 - 150 = 450 + expect(payload.position!.y).toBe(450) + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts index 75749e8121..46d50b7b2e 100644 --- a/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts +++ b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts @@ -1,21 +1,24 @@ import { useEventListener } from '@vueuse/core' import { ref } from 'vue' +import type { CompassCorners } from '@/lib/litegraph/src/interfaces' 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 { useTransformState } from '@/renderer/core/layout/transform/useTransformState' -interface ResizeCallbackPayload { +export interface ResizeCallbackPayload { size: Size + position?: Point } /** - * Composable for node resizing functionality (bottom-right corner only) + * Composable for node resizing functionality from any corner. * * Provides resize handle interaction that integrates with the layout system. - * Handles pointer capture, coordinate calculations, and size constraints. + * Handles pointer capture, coordinate calculations, size constraints, + * and position adjustments for non-SE corners. */ export function useNodeResize( resizeCallback: (payload: ResizeCallbackPayload, element: HTMLElement) => void @@ -25,14 +28,16 @@ export function useNodeResize( const isResizing = ref(false) const resizeStartPointer = ref(null) const resizeStartSize = ref(null) + const resizeStartPosition = ref(null) + const resizeCorner = ref('SE') // Snap-to-grid functionality - const { shouldSnap, applySnapToSize } = useNodeSnap() + const { shouldSnap, applySnapToPosition, applySnapToSize } = useNodeSnap() // Shift key sync for LiteGraph canvas preview const { trackShiftKey } = useShiftKeySync() - const startResize = (event: PointerEvent) => { + const startResize = (event: PointerEvent, corner: CompassCorners = 'SE') => { event.preventDefault() event.stopPropagation() @@ -42,6 +47,9 @@ export function useNodeResize( const nodeElement = target.closest('[data-node-id]') if (!(nodeElement instanceof HTMLElement)) return + const nodeId = nodeElement.dataset.nodeId + if (!nodeId) return + const rect = nodeElement.getBoundingClientRect() const scale = transformState.camera.z @@ -50,6 +58,16 @@ export function useNodeResize( height: rect.height / scale } + const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height') + nodeElement.style.setProperty('--node-height', '0px') + const minContentHeight = nodeElement.getBoundingClientRect().height / scale + nodeElement.style.setProperty('--node-height', savedNodeHeight || '') + + const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value + const startPosition: Point = nodeLayout + ? { ...nodeLayout.position } + : { x: 0, y: 0 } + // Track shift key state and sync to canvas for snap preview const stopShiftSync = trackShiftKey(event) @@ -61,12 +79,15 @@ export function useNodeResize( isResizing.value = true resizeStartPointer.value = { x: event.clientX, y: event.clientY } resizeStartSize.value = startSize + resizeStartPosition.value = startPosition + resizeCorner.value = corner const handlePointerMove = (moveEvent: PointerEvent) => { if ( !isResizing.value || !resizeStartPointer.value || - !resizeStartSize.value + !resizeStartSize.value || + !resizeStartPosition.value ) { return } @@ -77,19 +98,94 @@ export function useNodeResize( const deltaY = (moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1) - let newSize: Size = { - width: resizeStartSize.value.width + deltaX, - height: resizeStartSize.value.height + deltaY + const activeCorner = resizeCorner.value + let newWidth: number + let newHeight: number + let newX = resizeStartPosition.value.x + let newY = resizeStartPosition.value.y + + switch (activeCorner) { + case 'NE': + newY = resizeStartPosition.value.y + deltaY + newWidth = resizeStartSize.value.width + deltaX + newHeight = resizeStartSize.value.height - deltaY + break + case 'SW': + newX = resizeStartPosition.value.x + deltaX + newWidth = resizeStartSize.value.width - deltaX + newHeight = resizeStartSize.value.height + deltaY + break + case 'NW': + newX = resizeStartPosition.value.x + deltaX + newY = resizeStartPosition.value.y + deltaY + newWidth = resizeStartSize.value.width - deltaX + newHeight = resizeStartSize.value.height - deltaY + break + default: // SE + newWidth = resizeStartSize.value.width + deltaX + newHeight = resizeStartSize.value.height + deltaY + break } - // Apply snap if shift is held + // Apply snap-to-grid if (shouldSnap(moveEvent)) { - newSize = applySnapToSize(newSize) + // Snap position first for N/W corners, then compensate size + if (activeCorner.includes('N') || activeCorner.includes('W')) { + const originalX = newX + const originalY = newY + const snapped = applySnapToPosition({ x: newX, y: newY }) + newX = snapped.x + newY = snapped.y + + if (activeCorner.includes('N')) { + newHeight += originalY - newY + } + if (activeCorner.includes('W')) { + newWidth += originalX - newX + } + } + + const snappedSize = applySnapToSize({ + width: newWidth, + height: newHeight + }) + newWidth = snappedSize.width + newHeight = snappedSize.height } - const nodeElement = target.closest('[data-node-id]') - if (nodeElement instanceof HTMLElement) { - resizeCallback({ size: newSize }, nodeElement) + // Enforce minimum size with position compensation (matching litegraph) + const minWidth = + parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') || + 225 + if (newWidth < minWidth) { + if (activeCorner.includes('W')) { + newX = + resizeStartPosition.value.x + resizeStartSize.value.width - minWidth + } + newWidth = minWidth + } + if (newHeight < minContentHeight) { + if (activeCorner.includes('N')) { + newY = + resizeStartPosition.value.y + + resizeStartSize.value.height - + minContentHeight + } + newHeight = minContentHeight + } + + const payload: ResizeCallbackPayload = { + size: { width: newWidth, height: newHeight } + } + + // Only include position for non-SE corners + if (activeCorner !== 'SE') { + payload.position = { x: newX, y: newY } + } + + const targetNodeElement = target.closest('[data-node-id]') + if (targetNodeElement instanceof HTMLElement) { + resizeCallback(payload, targetNodeElement) } } @@ -99,6 +195,7 @@ export function useNodeResize( layoutStore.isResizingVueNodes.value = false resizeStartPointer.value = null resizeStartSize.value = null + resizeStartPosition.value = null // Stop tracking shift key state stopShiftSync()