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
This commit is contained in:
Terry Jia
2026-02-12 22:28:21 -05:00
committed by GitHub
parent 01cf3244b8
commit c52f48af45
4 changed files with 478 additions and 42 deletions

View File

@@ -162,33 +162,40 @@
</template>
</button>
</Button>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="
cn(
baseResizeHandleClasses,
'-right-1 -bottom-1 cursor-se-resize group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
class="w-2/5 h-2/5 top-1 left-1 absolute"
<template v-if="!isCollapsed && nodeData.resizable !== false">
<div
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:aria-label="t(handle.i18nKey)"
:class="
cn(
baseResizeHandleClasses,
handle.positionClasses,
handle.cursorClass,
'group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown($event, handle.corner)"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
:class="cn('w-2/5 h-2/5 absolute', handle.svgPositionClasses)"
:style="
handle.svgTransform ? { transform: handle.svgTransform } : undefined
"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>
</div>
</template>
@@ -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) => {

View File

@@ -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)'
}
]

View File

@@ -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> = {}
): 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<PointerEvent>)
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<ResizeCallback>
let nodeElement: HTMLElement
let handle: HTMLElement
beforeEach(async () => {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
callback = vi.fn<ResizeCallback>()
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<string, unknown>).__testStartResize = startResize
})
function getStartResize() {
return (globalThis as Record<string, unknown>).__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)
})
})
})

View File

@@ -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<Point | null>(null)
const resizeStartSize = ref<Size | null>(null)
const resizeStartPosition = ref<Point | null>(null)
const resizeCorner = ref<CompassCorners>('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()