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