allow Vue nodes to be resized from all 4 corners (#6187)

## Summary

Enables Vue nodes to resize from all four corners and consolidated the
interaction pipeline.

## Changes

- **What**: Added four-corner handles to `LGraphNode`, wired them
through the refactored `useNodeResize` composable, and centralized the
math/preset helpers under `interactions/resize/` with cleaner pure
functions and lint-compliant markup.

## Review Focus

Corner-to-corner resizing accuracy (position + size), pinned-node guard
preventing resize start, and snap-to-grid behavior at varied zoom
levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6187-allow-Vue-nodes-to-be-resized-from-all-4-corners-2936d73d365081c8bf14e944ab24c27f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2025-10-23 13:24:28 -07:00
committed by GitHub
parent aeabc24bf2
commit f14a6beda5
7 changed files with 441 additions and 80 deletions

View File

@@ -51,9 +51,11 @@ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: { width: 200, height: 100 },
zIndex: 0,
startDrag: vi.fn(),
handleDrag: vi.fn(),
endDrag: vi.fn()
endDrag: vi.fn(),
moveTo: vi.fn()
})
}))
@@ -77,7 +79,7 @@ vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
}))
}))
vi.mock('../composables/useNodeResize', () => ({
vi.mock('../interactions/resize/useNodeResize', () => ({
useNodeResize: vi.fn(() => ({
startResize: vi.fn(),
isResizing: computed(() => false)

View File

@@ -0,0 +1,160 @@
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 }
const minSize = { width: 120, height: 80 }
it('computes resize from bottom-right corner without moving position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 40, y: 20 },
minSize,
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 },
minSize,
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual({ width: 230, height: 140 })
expect(outcome.position).toEqual({ x: 50, y: 140 })
})
it('clamps to minimum size when shrinking below intrinsic size', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 500, y: 500 },
minSize,
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual(minSize)
expect(outcome.position).toEqual({
x: startPosition.x + (startSize.width - minSize.width),
y: startPosition.y + (startSize.height - minSize.height)
})
})
it('supports reusable resize sessions with snapping', () => {
const session = createResizeSession({
startSize,
startPosition,
minSize,
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 },
minSize,
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 },
minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size.width).toBe(10200)
expect(outcome.size.height).toBe(10120)
expect(outcome.position).toEqual(startPosition)
})
it('respects minimum size even with extreme negative deltas', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: -1000, y: -1000 },
minSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual(minSize)
expect(outcome.position).toEqual(startPosition)
})
it('handles minSize larger than startSize', () => {
const largeMinSize = { width: 300, height: 200 }
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 10, y: 10 },
minSize: largeMinSize,
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual(largeMinSize)
expect(outcome.position).toEqual(startPosition)
})
it('adjusts position correctly when minSize prevents shrinking from top-left', () => {
const largeMinSize = { width: 250, height: 150 }
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 100, y: 100 },
minSize: largeMinSize,
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual(largeMinSize)
expect(outcome.position).toEqual({
x: startPosition.x + (startSize.width - largeMinSize.width),
y: startPosition.y + (startSize.height - largeMinSize.height)
})
})
})
})