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()