diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index eee0c06589..c516f24e7c 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -200,7 +200,7 @@ --node-stroke-executing: var(--color-blue-100); --text-secondary: var(--color-stone-100); --text-primary: var(--color-charcoal-700); - --input-surface: rgba(0, 0, 0, 0.15); + --input-surface: rgb(0 0 0 / 0.15); } .dark-theme { @@ -247,7 +247,7 @@ --node-stroke-executing: var(--color-blue-100); --text-secondary: var(--color-slate-100); --text-primary: var(--color-pure-white); - --input-surface: rgba(130, 130, 130, 0.1); + --input-surface: rgb(130 130 130 / 0.1); } @theme inline { @@ -1139,7 +1139,7 @@ audio.comfy-audio.empty-audio-widget { } .isLOD .lg-node-header { - border-radius: 0px; + border-radius: 0; pointer-events: none; } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 210468449a..40b951b926 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 7ec7be2964..7ed7934cf0 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -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 && @@ -108,12 +108,17 @@ - -
+ +
@@ -121,6 +126,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' @@ -147,7 +153,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' @@ -165,6 +172,8 @@ interface LGraphNodeProps { const { nodeData, error = null } = defineProps() +const { t } = useI18n() + const { handleNodeCollapse, handleNodeTitleUpdate, @@ -243,8 +252,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 @@ -282,19 +290,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 diff --git a/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts b/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts new file mode 100644 index 0000000000..c58b0db6f8 --- /dev/null +++ b/src/renderer/extensions/vueNodes/interactions/resize/resizeMath.ts @@ -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 + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts similarity index 57% rename from src/renderer/extensions/vueNodes/composables/useNodeResize.ts rename to src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts index bf3fcc02fc..2566fe41b6 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts +++ b/src/renderer/extensions/vueNodes/interactions/resize/useNodeResize.ts @@ -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(null) - const resizeStartSize = ref(null) - const intrinsicMinSize = ref(null) + const resizeStartPointer = ref(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,85 +54,78 @@ 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 - // Track shift key state and sync to canvas for snap preview - const stopShiftSync = trackShiftKey(event) - - // Capture pointer to ensure we get all move/up events - 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 = { + const startSize: Size = { width: rect.width / scale, height: rect.height / scale } - // Calculate intrinsic content size (minimum based on content) - intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale) + const minSize = calculateIntrinsicSize(nodeElement, scale) + + // Track shift key state and sync to canvas for snap preview + const stopShiftSync = trackShiftKey(event) + + // Capture pointer to ensure we get all move/up events + target.setPointerCapture(event.pointerId) + + isResizing.value = true + 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 - - // Apply scale factor from transform state - const scale = transformState.camera.z - const scaledDx = dx / scale - const scaledDy = dy / scale - - // 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 - ) - } + const startPointer = resizeStartPointer.value + const session = resizeSession.value - // Apply snap-to-grid if shift is held or always snap is enabled - const finalSize = shouldSnap(moveEvent) - ? applySnapToSize(constrainedSize) - : constrainedSize + const delta = toCanvasDelta( + startPointer, + { x: moveEvent.clientX, y: moveEvent.clientY }, + transformState.camera.z + ) - // 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() diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 03f0cbf038..e770c0aeab 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -253,7 +253,7 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter) { /** * 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) { isDragging, // Mutations - moveTo, + moveNodeTo, // Drag handlers startDrag, diff --git a/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue b/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue index 59ed07bde0..ccdf6e47a6 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue @@ -388,7 +388,7 @@ onUnmounted(() => { diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts index fbeb9dd123..e41608e731 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts @@ -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) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts new file mode 100644 index 0000000000..dd59534f16 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/interactions/resize/resizeMath.test.ts @@ -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) + }) + }) + }) +})