Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/renderer/core/layout/injectionKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/extensions/vueNodes/components/LGraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@
</div>
</div>
</template>

<!-- Resize handle -->
<div
v-if="!isCollapsed"
class="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize opacity-0 hover:opacity-20 hover:bg-white transition-opacity duration-200"
@pointerdown.stop="startResize"
/>
</div>
</template>

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
)
}
Comment on lines +184 to +188
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be worth keeping as a console.error to start, then letting useNodeResize degrade gracefully if it's not provided.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? This should always exist.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a conflict between your console.error suggestion and @christian-byrne's view that TransformState should always exist. Since this is an internal component with guaranteed injection, I kept the current error throwing approach for fail-fast debugging.


// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
Expand Down Expand Up @@ -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)

Expand Down
135 changes: 135 additions & 0 deletions src/renderer/extensions/vueNodes/composables/useNodeResize.ts
Original file line number Diff line number Diff line change
@@ -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<Position | null>(null)
const resizeStartSize = ref<Size | null>(null)
const intrinsicMinSize = ref<Size | null>(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)
Comment on lines +127 to +128
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding the listeners within function calls might be part of how we're leaking so many.
The handlers are already ignoring events if we're not resizing, so would it be safe to just pull them out and invert some of the start/stop logic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding the listeners within function calls might be part of how we're leaking so many

What does this mean exactly?

The handlers are already ignoring events if we're not resizing, so would it be safe to just pull them out and invert some of the start/stop logic

So have the listeners active across the node lifecycle instead of just during resize? Isn't that worse?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Analyzed this approach but the current implementation is actually optimal - listeners only exist during resize operations (seconds), avoiding unnecessary event processing. useEventListener handles cleanup properly, so no leaks occur.

}

return {
startResize,
isResizing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -96,6 +104,14 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
}),
i18n
],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: {
NodeHeader: true,
NodeSlots: true,
Expand Down Expand Up @@ -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,
Expand Down
Loading