mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 07:44:11 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user