[backport cloud/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7068)

## Summary
- Backport of #7063 to cloud/1.33
- Simplifies Vue node resize to bottom-right corner only

Cherry-picked from d76c59cb14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7068-backport-cloud-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508149bd85c3ae534387b6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-11-30 20:20:35 -08:00
committed by GitHub
parent 3856e0deea
commit ffa55cb92b
6 changed files with 35 additions and 301 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -117,17 +117,14 @@
</div>
</template>
<!-- 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>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
</template>
@@ -171,7 +168,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
@@ -263,7 +259,7 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
@@ -314,41 +310,6 @@ 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 MIN_NODE_WIDTH = 225
@@ -361,22 +322,11 @@ 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`)
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)
}
})
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
return (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event, direction, { ...position.value })
}
const handleResizePointerDown = (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event)
}
watch(isCollapsed, (collapsed) => {

View File

@@ -1,104 +0,0 @@
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 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,
handle,
snapFn
}: {
startSize: Size
startPosition: Point
delta: Point
handle: ResizeHandleDirection
snapFn?: (size: Size) => Size
}): { size: Size; position: Point } {
const resized = applyHandleDelta(startSize, delta, handle)
const snapped = snapFn?.(resized) ?? resized
const position = computeAdjustedPosition(
startPosition,
startSize,
snapped,
handle
)
return {
size: snapped,
position
}
}
export function createResizeSession(config: {
startSize: Size
startPosition: Point
handle: ResizeHandleDirection
}) {
const startSize = { ...config.startSize }
const startPosition = { ...config.startPosition }
const handle = config.handle
return (delta: Point, snapFn?: (size: Size) => Size) =>
computeResizeOutcome({
startSize,
startPosition,
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

@@ -4,18 +4,14 @@ import { ref } from 'vue'
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 type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface ResizeCallbackPayload {
size: Size
position: Point
}
/**
* Composable for node resizing functionality
* Composable for node resizing functionality (bottom-right corner only)
*
* Provides resize handle interaction that integrates with the layout system.
* Handles pointer capture, coordinate calculations, and size constraints.
@@ -27,16 +23,7 @@ export function useNodeResize(
const isResizing = ref(false)
const resizeStartPointer = ref<Point | null>(null)
const resizeSession = ref<
| ((
delta: Point,
snapFn?: (size: Size) => Size
) => {
size: Size
position: Point
})
| null
>(null)
const resizeStartSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
@@ -44,11 +31,7 @@ export function useNodeResize(
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (
event: PointerEvent,
handle: ResizeHandleDirection,
startPosition: Point
) => {
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -74,37 +57,36 @@ export function useNodeResize(
isResizing.value = true
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
resizeSession.value = createResizeSession({
startSize,
startPosition: { ...startPosition },
handle
})
resizeStartSize.value = startSize
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPointer.value ||
!resizeSession.value
)
!resizeStartSize.value
) {
return
}
const startPointer = resizeStartPointer.value
const session = resizeSession.value
const scale = transformState.camera.z
const deltaX =
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
const deltaY =
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
const delta = toCanvasDelta(
startPointer,
{ x: moveEvent.clientX, y: moveEvent.clientY },
transformState.camera.z
)
let newSize: Size = {
width: resizeStartSize.value.width + deltaX,
height: resizeStartSize.value.height + deltaY
}
// Apply snap if shift is held
if (shouldSnap(moveEvent)) {
newSize = applySnapToSize(newSize)
}
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
const outcome = session(
delta,
shouldSnap(moveEvent) ? applySnapToSize : undefined
)
resizeCallback(outcome, nodeElement)
resizeCallback({ size: newSize }, nodeElement)
}
}
@@ -112,7 +94,7 @@ export function useNodeResize(
if (isResizing.value) {
isResizing.value = false
resizeStartPointer.value = null
resizeSession.value = null
resizeStartSize.value = null
// Stop tracking shift key state
stopShiftSync()

View File

@@ -1,94 +0,0 @@
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 }
it('computes resize from bottom-right corner without moving position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 40, y: 20 },
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 },
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual({ width: 230, height: 140 })
expect(outcome.position).toEqual({ x: 50, y: 140 })
})
it('supports reusable resize sessions with snapping', () => {
const session = createResizeSession({
startSize,
startPosition,
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 },
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 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size.width).toBe(10200)
expect(outcome.size.height).toBe(10120)
expect(outcome.position).toEqual(startPosition)
})
})
})