diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png index a11d96c370..de1f50b56f 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/src/renderer/core/layout/injectionKeys.ts b/src/renderer/core/layout/injectionKeys.ts index 8e0e0e1d60..8970e55f8b 100644 --- a/src/renderer/core/layout/injectionKeys.ts +++ b/src/renderer/core/layout/injectionKeys.ts @@ -21,7 +21,7 @@ import type { useTransformState } from '@/renderer/core/layout/transform/useTran * const state = inject(TransformStateKey)! * const screen = state.canvasToScreen({ x: 100, y: 50 }) */ -interface TransformState +export interface TransformState extends Pick< ReturnType, 'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport' diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index cfd3411dee..1213936cb9 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -113,6 +113,13 @@ + + +
@@ -145,6 +152,7 @@ import { } from '@/utils/graphTraversalUtil' import { cn } from '@/utils/tailwindUtil' +import { useNodeResize } from '../composables/useNodeResize' import NodeContent from './NodeContent.vue' import NodeHeader from './NodeHeader.vue' import NodeSlots from './NodeSlots.vue' @@ -173,6 +181,11 @@ const { selectedNodeIds } = storeToRefs(useCanvasStore()) // Inject transform state for coordinate conversion const transformState = inject(TransformStateKey) +if (!transformState) { + throw new Error( + 'TransformState must be provided for node resize functionality' + ) +} // Computed selection state - only this node re-evaluates when its selection changes const isSelected = computed(() => { @@ -264,6 +277,19 @@ onMounted(() => { } }) +const { startResize } = useNodeResize( + (newSize, element) => { + // Apply size directly to DOM element - ResizeObserver will pick this up + if (isCollapsed.value) return + + element.style.width = `${newSize.width}px` + element.style.height = `${newSize.height}px` + }, + { + transformState + } +) + // Track collapsed state const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false) diff --git a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts new file mode 100644 index 0000000000..a216646e23 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts @@ -0,0 +1,135 @@ +import { useEventListener } from '@vueuse/core' +import { ref } from 'vue' + +import type { TransformState } from '@/renderer/core/layout/injectionKeys' + +interface Size { + width: number + height: number +} + +interface Position { + x: number + y: number +} + +interface UseNodeResizeOptions { + /** Transform state for coordinate conversion */ + transformState: TransformState +} + +/** + * Composable for node resizing functionality + * + * Provides resize handle interaction that integrates with the layout system. + * Handles pointer capture, coordinate calculations, and size constraints. + */ +export function useNodeResize( + resizeCallback: (size: Size, 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 startResize = (event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + // 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() + + // Calculate intrinsic content size once at start + const originalWidth = nodeElement.style.width + const originalHeight = nodeElement.style.height + nodeElement.style.width = 'auto' + nodeElement.style.height = 'auto' + + const intrinsicRect = nodeElement.getBoundingClientRect() + + // Restore original size + nodeElement.style.width = originalWidth + nodeElement.style.height = originalHeight + + // Convert to canvas coordinates using transform state + const scale = transformState.camera.z + resizeStartSize.value = { + width: rect.width / scale, + height: rect.height / scale + } + intrinsicMinSize.value = { + width: intrinsicRect.width / scale, + height: intrinsicRect.height / scale + } + + const handlePointerMove = (moveEvent: PointerEvent) => { + if ( + !isResizing.value || + !resizeStartPos.value || + !resizeStartSize.value || + !intrinsicMinSize.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 newWidth = Math.max( + intrinsicMinSize.value.width, + resizeStartSize.value.width + scaledDx + ) + const newHeight = Math.max( + intrinsicMinSize.value.height, + resizeStartSize.value.height + scaledDy + ) + + // Get the node element to apply size directly + const nodeElement = target.closest('[data-node-id]') + if (nodeElement instanceof HTMLElement) { + resizeCallback({ width: newWidth, height: newHeight }, nodeElement) + } + } + + const handlePointerUp = (upEvent: PointerEvent) => { + if (isResizing.value) { + isResizing.value = false + resizeStartPos.value = null + resizeStartSize.value = null + intrinsicMinSize.value = null + + target.releasePointerCapture(upEvent.pointerId) + stopMoveListen() + stopUpListen() + } + } + + const stopMoveListen = useEventListener('pointermove', handlePointerMove) + const stopUpListen = useEventListener('pointerup', handlePointerUp) + } + + return { + startResize, + isResizing + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index e8c38164d4..e0c0e7d842 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -20,6 +20,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { Bounds, NodeId } from '@/renderer/core/layout/types' +import { LayoutSource } from '@/renderer/core/layout/types' import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking' @@ -124,6 +125,9 @@ const resizeObserver = new ResizeObserver((entries) => { } } + // Set source to Vue before processing DOM-driven updates + layoutStore.setSource(LayoutSource.Vue) + // Flush per-type for (const [type, updates] of updatesByType) { const config = trackingConfigs.get(type) 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 2d10edb2d6..90f64e1a22 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts @@ -6,6 +6,7 @@ import type { ComponentProps } from 'vue-component-type-helpers' import { createI18n } from 'vue-i18n' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' @@ -77,6 +78,13 @@ vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({ })) })) +vi.mock('../composables/useNodeResize', () => ({ + useNodeResize: vi.fn(() => ({ + startResize: vi.fn(), + isResizing: computed(() => false) + })) +})) + const i18n = createI18n({ legacy: false, locale: 'en', @@ -96,6 +104,14 @@ function mountLGraphNode(props: ComponentProps) { }), i18n ], + provide: { + [TransformStateKey as symbol]: { + screenToCanvas: vi.fn(), + canvasToScreen: vi.fn(), + camera: { z: 1 }, + isNodeInViewport: vi.fn() + } + }, stubs: { NodeHeader: true, NodeSlots: true, @@ -155,6 +171,14 @@ describe('LGraphNode', () => { }), i18n ], + provide: { + [TransformStateKey as symbol]: { + screenToCanvas: vi.fn(), + canvasToScreen: vi.fn(), + camera: { z: 1 }, + isNodeInViewport: vi.fn() + } + }, stubs: { NodeSlots: true, NodeWidgets: true,