Skip to content

Commit d915792

Browse files
christian-byrneclaudeDrJKLgithub-actions
authored
make Vue nodes resizable (#5936)
## Summary Implemented node resizing functionality for Vue nodes. https://github.com/user-attachments/assets/a7536045-1fa5-401b-8d18-7c26b4dfbfc3 Resolves #5675. ## Review Focus ResizeObserver as single source of truth pattern eliminates feedback loops between manual resize and reactive layout updates. Intrinsic content sizing calculation temporarily resets DOM styles to measure natural content dimensions. ```mermaid graph TD A[User Drags Handle] --> B[Direct DOM Style Update] B --> C[ResizeObserver Detects Change] C --> D[Layout Store Update] D --> E[Slot Position Sync] style A fill:#f9f9f9,stroke:#333,color:#000 style E fill:#f9f9f9,stroke:#333,color:#000 ``` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5936-make-Vue-nodes-resizable-2846d73d36508160b3b9db49ad8b273e) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: DrJKL <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent 89f4452 commit d915792

File tree

6 files changed

+190
-1
lines changed

6 files changed

+190
-1
lines changed
-251 Bytes
Loading

src/renderer/core/layout/injectionKeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { useTransformState } from '@/renderer/core/layout/transform/useTran
2121
* const state = inject(TransformStateKey)!
2222
* const screen = state.canvasToScreen({ x: 100, y: 50 })
2323
*/
24-
interface TransformState
24+
export interface TransformState
2525
extends Pick<
2626
ReturnType<typeof useTransformState>,
2727
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'

src/renderer/extensions/vueNodes/components/LGraphNode.vue

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@
113113
</div>
114114
</div>
115115
</template>
116+
117+
<!-- Resize handle -->
118+
<div
119+
v-if="!isCollapsed"
120+
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"
121+
@pointerdown.stop="startResize"
122+
/>
116123
</div>
117124
</template>
118125

@@ -145,6 +152,7 @@ import {
145152
} from '@/utils/graphTraversalUtil'
146153
import { cn } from '@/utils/tailwindUtil'
147154
155+
import { useNodeResize } from '../composables/useNodeResize'
148156
import NodeContent from './NodeContent.vue'
149157
import NodeHeader from './NodeHeader.vue'
150158
import NodeSlots from './NodeSlots.vue'
@@ -173,6 +181,11 @@ const { selectedNodeIds } = storeToRefs(useCanvasStore())
173181
174182
// Inject transform state for coordinate conversion
175183
const transformState = inject(TransformStateKey)
184+
if (!transformState) {
185+
throw new Error(
186+
'TransformState must be provided for node resize functionality'
187+
)
188+
}
176189
177190
// Computed selection state - only this node re-evaluates when its selection changes
178191
const isSelected = computed(() => {
@@ -264,6 +277,19 @@ onMounted(() => {
264277
}
265278
})
266279
280+
const { startResize } = useNodeResize(
281+
(newSize, element) => {
282+
// Apply size directly to DOM element - ResizeObserver will pick this up
283+
if (isCollapsed.value) return
284+
285+
element.style.width = `${newSize.width}px`
286+
element.style.height = `${newSize.height}px`
287+
},
288+
{
289+
transformState
290+
}
291+
)
292+
267293
// Track collapsed state
268294
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
269295
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useEventListener } from '@vueuse/core'
2+
import { ref } from 'vue'
3+
4+
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
5+
6+
interface Size {
7+
width: number
8+
height: number
9+
}
10+
11+
interface Position {
12+
x: number
13+
y: number
14+
}
15+
16+
interface UseNodeResizeOptions {
17+
/** Transform state for coordinate conversion */
18+
transformState: TransformState
19+
}
20+
21+
/**
22+
* Composable for node resizing functionality
23+
*
24+
* Provides resize handle interaction that integrates with the layout system.
25+
* Handles pointer capture, coordinate calculations, and size constraints.
26+
*/
27+
export function useNodeResize(
28+
resizeCallback: (size: Size, element: HTMLElement) => void,
29+
options: UseNodeResizeOptions
30+
) {
31+
const { transformState } = options
32+
33+
const isResizing = ref(false)
34+
const resizeStartPos = ref<Position | null>(null)
35+
const resizeStartSize = ref<Size | null>(null)
36+
const intrinsicMinSize = ref<Size | null>(null)
37+
38+
const startResize = (event: PointerEvent) => {
39+
event.preventDefault()
40+
event.stopPropagation()
41+
42+
const target = event.currentTarget
43+
if (!(target instanceof HTMLElement)) return
44+
45+
// Capture pointer to ensure we get all move/up events
46+
target.setPointerCapture(event.pointerId)
47+
48+
isResizing.value = true
49+
resizeStartPos.value = { x: event.clientX, y: event.clientY }
50+
51+
// Get current node size from the DOM and calculate intrinsic min size
52+
const nodeElement = target.closest('[data-node-id]')
53+
if (!(nodeElement instanceof HTMLElement)) return
54+
55+
const rect = nodeElement.getBoundingClientRect()
56+
57+
// Calculate intrinsic content size once at start
58+
const originalWidth = nodeElement.style.width
59+
const originalHeight = nodeElement.style.height
60+
nodeElement.style.width = 'auto'
61+
nodeElement.style.height = 'auto'
62+
63+
const intrinsicRect = nodeElement.getBoundingClientRect()
64+
65+
// Restore original size
66+
nodeElement.style.width = originalWidth
67+
nodeElement.style.height = originalHeight
68+
69+
// Convert to canvas coordinates using transform state
70+
const scale = transformState.camera.z
71+
resizeStartSize.value = {
72+
width: rect.width / scale,
73+
height: rect.height / scale
74+
}
75+
intrinsicMinSize.value = {
76+
width: intrinsicRect.width / scale,
77+
height: intrinsicRect.height / scale
78+
}
79+
80+
const handlePointerMove = (moveEvent: PointerEvent) => {
81+
if (
82+
!isResizing.value ||
83+
!resizeStartPos.value ||
84+
!resizeStartSize.value ||
85+
!intrinsicMinSize.value
86+
)
87+
return
88+
89+
const dx = moveEvent.clientX - resizeStartPos.value.x
90+
const dy = moveEvent.clientY - resizeStartPos.value.y
91+
92+
// Apply scale factor from transform state
93+
const scale = transformState.camera.z
94+
const scaledDx = dx / scale
95+
const scaledDy = dy / scale
96+
97+
// Apply constraints: only minimum size based on content, no maximum
98+
const newWidth = Math.max(
99+
intrinsicMinSize.value.width,
100+
resizeStartSize.value.width + scaledDx
101+
)
102+
const newHeight = Math.max(
103+
intrinsicMinSize.value.height,
104+
resizeStartSize.value.height + scaledDy
105+
)
106+
107+
// Get the node element to apply size directly
108+
const nodeElement = target.closest('[data-node-id]')
109+
if (nodeElement instanceof HTMLElement) {
110+
resizeCallback({ width: newWidth, height: newHeight }, nodeElement)
111+
}
112+
}
113+
114+
const handlePointerUp = (upEvent: PointerEvent) => {
115+
if (isResizing.value) {
116+
isResizing.value = false
117+
resizeStartPos.value = null
118+
resizeStartSize.value = null
119+
intrinsicMinSize.value = null
120+
121+
target.releasePointerCapture(upEvent.pointerId)
122+
stopMoveListen()
123+
stopUpListen()
124+
}
125+
}
126+
127+
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
128+
const stopUpListen = useEventListener('pointerup', handlePointerUp)
129+
}
130+
131+
return {
132+
startResize,
133+
isResizing
134+
}
135+
}

src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
2020
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
2121
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
2222
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
23+
import { LayoutSource } from '@/renderer/core/layout/types'
2324

2425
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
2526

@@ -124,6 +125,9 @@ const resizeObserver = new ResizeObserver((entries) => {
124125
}
125126
}
126127

128+
// Set source to Vue before processing DOM-driven updates
129+
layoutStore.setSource(LayoutSource.Vue)
130+
127131
// Flush per-type
128132
for (const [type, updates] of updatesByType) {
129133
const config = trackingConfigs.get(type)

tests-ui/tests/renderer/extensions/vueNodes/components/LGraphNode.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
66
import { createI18n } from 'vue-i18n'
77

88
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
9+
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
910
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
1011
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
1112
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
@@ -77,6 +78,13 @@ vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
7778
}))
7879
}))
7980

81+
vi.mock('../composables/useNodeResize', () => ({
82+
useNodeResize: vi.fn(() => ({
83+
startResize: vi.fn(),
84+
isResizing: computed(() => false)
85+
}))
86+
}))
87+
8088
const i18n = createI18n({
8189
legacy: false,
8290
locale: 'en',
@@ -96,6 +104,14 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
96104
}),
97105
i18n
98106
],
107+
provide: {
108+
[TransformStateKey as symbol]: {
109+
screenToCanvas: vi.fn(),
110+
canvasToScreen: vi.fn(),
111+
camera: { z: 1 },
112+
isNodeInViewport: vi.fn()
113+
}
114+
},
99115
stubs: {
100116
NodeHeader: true,
101117
NodeSlots: true,
@@ -155,6 +171,14 @@ describe('LGraphNode', () => {
155171
}),
156172
i18n
157173
],
174+
provide: {
175+
[TransformStateKey as symbol]: {
176+
screenToCanvas: vi.fn(),
177+
canvasToScreen: vi.fn(),
178+
camera: { z: 1 },
179+
isNodeInViewport: vi.fn()
180+
}
181+
},
158182
stubs: {
159183
NodeSlots: true,
160184
NodeWidgets: true,

0 commit comments

Comments
 (0)