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

@@ -62,6 +62,10 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",
"resizeFromTopLeft": "Resize from top-left corner",
"info": "Node Info",
"bookmark": "Save to Library",
"moreOptions": "More Options",

View File

@@ -9,7 +9,7 @@
:class="
cn(
'bg-node-component-surface',
'lg-node absolute rounded-2xl touch-none flex flex-col',
'lg-node absolute rounded-2xl touch-none flex flex-col group',
'border-1 border-solid border-node-component-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
@@ -107,12 +107,17 @@
</div>
</template>
<!-- Resize handle -->
<div
v-if="!isCollapsed"
class="absolute right-0 bottom-0 h-3 w-3 cursor-se-resize opacity-0 transition-opacity duration-200 hover:bg-white hover:opacity-20"
@pointerdown.stop="startResize"
/>
<!-- Resize handles -->
<template v-if="!isCollapsed">
<div
v-for="handle in cornerResizeHandles"
:key="handle.id"
role="button"
:aria-label="handle.ariaLabel"
:class="cn(baseResizeHandleClasses, handle.classes)"
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
/>
</template>
</div>
</template>
@@ -120,6 +125,7 @@
import { whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
@@ -146,7 +152,8 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../composables/useNodeResize'
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
@@ -164,6 +171,8 @@ interface LGraphNodeProps {
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const {
handleNodeCollapse,
handleNodeTitleUpdate,
@@ -242,8 +251,7 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
// Use layout system for node position and dragging
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
() => nodeData,
handleNodeSelect
@@ -281,19 +289,73 @@ onMounted(() => {
}
})
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const POSITION_EPSILON = 0.01
type CornerResizeHandle = {
id: string
direction: ResizeHandleDirection
classes: string
ariaLabel: string
}
const cornerResizeHandles: CornerResizeHandle[] = [
{
id: 'se',
direction: { horizontal: 'right', vertical: 'bottom' },
classes: 'right-0 bottom-0 cursor-se-resize',
ariaLabel: t('g.resizeFromBottomRight')
},
{
id: 'ne',
direction: { horizontal: 'right', vertical: 'top' },
classes: 'right-0 top-0 cursor-ne-resize',
ariaLabel: t('g.resizeFromTopRight')
},
{
id: 'sw',
direction: { horizontal: 'left', vertical: 'bottom' },
classes: 'left-0 bottom-0 cursor-sw-resize',
ariaLabel: t('g.resizeFromBottomLeft')
},
{
id: 'nw',
direction: { horizontal: 'left', vertical: 'top' },
classes: 'left-0 top-0 cursor-nw-resize',
ariaLabel: t('g.resizeFromTopLeft')
}
]
const { startResize } = useNodeResize(
(newSize, element) => {
// Apply size directly to DOM element - ResizeObserver will pick this up
(result, element) => {
if (isCollapsed.value) return
element.style.width = `${newSize.width}px`
element.style.height = `${newSize.height}px`
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.width = `${result.size.width}px`
element.style.height = `${result.size.height}px`
const currentPosition = position.value
const deltaX = Math.abs(result.position.x - currentPosition.x)
const deltaY = Math.abs(result.position.y - currentPosition.y)
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
moveNodeTo(result.position)
}
},
{
transformState
}
)
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
return (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event, direction, { ...position.value })
}
}
whenever(isCollapsed, () => {
const element = nodeContainerRef.value
if (!element) return

View File

@@ -0,0 +1,130 @@
import type { Point, Size } from '@/renderer/core/layout/types'
export type ResizeHandleDirection = {
horizontal: 'left' | 'right'
vertical: 'top' | 'bottom'
}
function applyHandleDelta(
startSize: Size,
delta: Point,
handle: ResizeHandleDirection
): Size {
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
return {
width: startSize.width + delta.x * horizontalMultiplier,
height: startSize.height + delta.y * verticalMultiplier
}
}
function clampToMinSize(size: Size, minSize: Size): Size {
return {
width: Math.max(size.width, minSize.width),
height: Math.max(size.height, minSize.height)
}
}
function snapSize(
size: Size,
minSize: Size,
snapFn?: (size: Size) => Size
): Size {
if (!snapFn) return size
const snapped = snapFn(size)
return {
width: Math.max(minSize.width, snapped.width),
height: Math.max(minSize.height, snapped.height)
}
}
function computeAdjustedPosition(
startPosition: Point,
startSize: Size,
nextSize: Size,
handle: ResizeHandleDirection
): Point {
const widthDelta = startSize.width - nextSize.width
const heightDelta = startSize.height - nextSize.height
return {
x:
handle.horizontal === 'left'
? startPosition.x + widthDelta
: startPosition.x,
y:
handle.vertical === 'top'
? startPosition.y + heightDelta
: startPosition.y
}
}
/**
* Computes the resulting size and position of a node given pointer movement
* and handle orientation.
*/
export function computeResizeOutcome({
startSize,
startPosition,
delta,
minSize,
handle,
snapFn
}: {
startSize: Size
startPosition: Point
delta: Point
minSize: Size
handle: ResizeHandleDirection
snapFn?: (size: Size) => Size
}): { size: Size; position: Point } {
const resized = applyHandleDelta(startSize, delta, handle)
const clamped = clampToMinSize(resized, minSize)
const snapped = snapSize(clamped, minSize, snapFn)
const position = computeAdjustedPosition(
startPosition,
startSize,
snapped,
handle
)
return {
size: snapped,
position
}
}
export function createResizeSession(config: {
startSize: Size
startPosition: Point
minSize: Size
handle: ResizeHandleDirection
}) {
const startSize = { ...config.startSize }
const startPosition = { ...config.startPosition }
const minSize = { ...config.minSize }
const handle = config.handle
return (delta: Point, snapFn?: (size: Size) => Size) =>
computeResizeOutcome({
startSize,
startPosition,
minSize,
handle,
delta,
snapFn
})
}
export function toCanvasDelta(
startPointer: Point,
currentPointer: Point,
scale: number
): Point {
const safeScale = scale === 0 ? 1 : scale
return {
x: (currentPointer.x - startPointer.x) / safeScale,
y: (currentPointer.y - startPointer.y) / safeScale
}
}

View File

@@ -2,25 +2,24 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import type { Point, Size } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
interface Size {
width: number
height: number
}
interface Position {
x: number
y: number
}
import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
interface UseNodeResizeOptions {
/** Transform state for coordinate conversion */
transformState: TransformState
}
interface ResizeCallbackPayload {
size: Size
position: Point
}
/**
* Composable for node resizing functionality
*
@@ -28,15 +27,26 @@ interface UseNodeResizeOptions {
* Handles pointer capture, coordinate calculations, and size constraints.
*/
export function useNodeResize(
resizeCallback: (size: Size, element: HTMLElement) => void,
resizeCallback: (
payload: ResizeCallbackPayload,
element: HTMLElement
) => void,
options: UseNodeResizeOptions
) {
const { transformState } = options
const isResizing = ref(false)
const resizeStartPos = ref<Position | null>(null)
const resizeStartSize = ref<Size | null>(null)
const intrinsicMinSize = ref<Size | null>(null)
const resizeStartPointer = ref<Point | null>(null)
const resizeSession = ref<
| ((
delta: Point,
snapFn?: (size: Size) => Size
) => {
size: Size
position: Point
})
| null
>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
@@ -44,13 +54,30 @@ export function useNodeResize(
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (event: PointerEvent) => {
const startResize = (
event: PointerEvent,
handle: ResizeHandleDirection,
startPosition: Point
) => {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
const nodeElement = target.closest('[data-node-id]')
if (!(nodeElement instanceof HTMLElement)) return
const rect = nodeElement.getBoundingClientRect()
const scale = transformState.camera.z
const startSize: Size = {
width: rect.width / scale,
height: rect.height / scale
}
const minSize = calculateIntrinsicSize(nodeElement, scale)
// Track shift key state and sync to canvas for snap preview
const stopShiftSync = trackShiftKey(event)
@@ -58,71 +85,47 @@ export function useNodeResize(
target.setPointerCapture(event.pointerId)
isResizing.value = true
resizeStartPos.value = { x: event.clientX, y: event.clientY }
// Get current node size from the DOM and calculate intrinsic min size
const nodeElement = target.closest('[data-node-id]')
if (!(nodeElement instanceof HTMLElement)) return
const rect = nodeElement.getBoundingClientRect()
const scale = transformState.camera.z
// Calculate current size in canvas coordinates
resizeStartSize.value = {
width: rect.width / scale,
height: rect.height / scale
}
// Calculate intrinsic content size (minimum based on content)
intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale)
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
resizeSession.value = createResizeSession({
startSize,
startPosition: { ...startPosition },
minSize,
handle
})
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPos.value ||
!resizeStartSize.value ||
!intrinsicMinSize.value
!resizeStartPointer.value ||
!resizeSession.value
)
return
const dx = moveEvent.clientX - resizeStartPos.value.x
const dy = moveEvent.clientY - resizeStartPos.value.y
const startPointer = resizeStartPointer.value
const session = resizeSession.value
// Apply scale factor from transform state
const scale = transformState.camera.z
const scaledDx = dx / scale
const scaledDy = dy / scale
const delta = toCanvasDelta(
startPointer,
{ x: moveEvent.clientX, y: moveEvent.clientY },
transformState.camera.z
)
// Apply constraints: only minimum size based on content, no maximum
const constrainedSize = {
width: Math.max(
intrinsicMinSize.value.width,
resizeStartSize.value.width + scaledDx
),
height: Math.max(
intrinsicMinSize.value.height,
resizeStartSize.value.height + scaledDy
)
}
// Apply snap-to-grid if shift is held or always snap is enabled
const finalSize = shouldSnap(moveEvent)
? applySnapToSize(constrainedSize)
: constrainedSize
// Get the node element to apply size directly
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
resizeCallback(finalSize, nodeElement)
const outcome = session(
delta,
shouldSnap(moveEvent) ? applySnapToSize : undefined
)
resizeCallback(outcome, nodeElement)
}
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
resizeStartPos.value = null
resizeStartSize.value = null
intrinsicMinSize.value = null
resizeStartPointer.value = null
resizeSession.value = null
// Stop tracking shift key state
stopShiftSync()

View File

@@ -253,7 +253,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
/**
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
function moveNodeTo(position: Point) {
mutations.setSource(LayoutSource.Vue)
mutations.moveNode(nodeId, position)
}
@@ -269,7 +269,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
isDragging,
// Mutations
moveTo,
moveNodeTo,
// Drag handlers
startDrag,

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