Skip to content

Commit 9da82f4

Browse files
DrJKLgithub-actions
andauthored
Feat: Alt+Drag to clone - Vue Nodes (#6789)
## Summary Replicate the alt+drag to clone behavior present in litegraph. ## Changes - **What**: Simplify the interaction/drag handling, now with less state! - **What**: Alt+Click+Drag a node to clone it ## Screenshots (if applicable) https://github.com/user-attachments/assets/469e33c2-de0c-4e64-a344-1e9d9339d528 <!-- Add screenshots or video recording to help explain your changes --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6789-WIP-Alt-Drag-to-clone-Vue-Nodes-2b16d73d36508102a871ffe97ed2831f) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
1 parent a8d6f7b commit 9da82f4

File tree

22 files changed

+571
-1565
lines changed

22 files changed

+571
-1565
lines changed

browser_tests/fixtures/ComfyPage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ export class ComfyPage {
557557
async dragAndDrop(source: Position, target: Position) {
558558
await this.page.mouse.move(source.x, source.y)
559559
await this.page.mouse.down()
560-
await this.page.mouse.move(target.x, target.y)
560+
await this.page.mouse.move(target.x, target.y, { steps: 100 })
561561
await this.page.mouse.up()
562562
await this.nextFrame()
563563
}
169 Bytes
Loading
1.58 KB
Loading

src/composables/graph/useGraphNodeManager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
1414
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
1515
import { LayoutSource } from '@/renderer/core/layout/types'
16+
import type { NodeId } from '@/renderer/core/layout/types'
1617
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
1718
import { isDOMWidget } from '@/scripts/domWidget'
1819
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
4647
}
4748

4849
export interface VueNodeData {
49-
id: string
50+
id: NodeId
5051
title: string
5152
type: string
5253
mode: number

src/lib/litegraph/src/LGraphCanvas.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,18 +1771,19 @@ export class LGraphCanvas
17711771
}
17721772

17731773
static onMenuNodeClone(
1774-
// @ts-expect-error - unused parameter
1775-
value: IContextMenuValue,
1776-
// @ts-expect-error - unused parameter
1777-
options: IContextMenuOptions,
1778-
// @ts-expect-error - unused parameter
1779-
e: MouseEvent,
1780-
// @ts-expect-error - unused parameter
1781-
menu: ContextMenu,
1774+
_value: IContextMenuValue,
1775+
_options: IContextMenuOptions,
1776+
_e: MouseEvent,
1777+
_menu: ContextMenu,
17821778
node: LGraphNode
17831779
): void {
17841780
const canvas = LGraphCanvas.active_canvas
1785-
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
1781+
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
1782+
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
1783+
}
1784+
1785+
static cloneNodes(nodes: Positionable[]) {
1786+
const canvas = LGraphCanvas.active_canvas
17861787

17871788
// Find top-left-most boundary
17881789
let offsetX = Infinity
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
17921793
throw new TypeError(
17931794
'Invalid node encountered on clone. `pos` was null.'
17941795
)
1795-
if (item.pos[0] < offsetX) offsetX = item.pos[0]
1796-
if (item.pos[1] < offsetY) offsetY = item.pos[1]
1796+
offsetX = Math.min(offsetX, item.pos[0])
1797+
offsetY = Math.min(offsetY, item.pos[1])
17971798
}
17981799

1799-
canvas._deserializeItems(canvas._serializeItems(nodes), {
1800+
return canvas._deserializeItems(canvas._serializeItems(nodes), {
18001801
position: [offsetX + 5, offsetY + 5]
18011802
})
18021803
}

src/renderer/core/layout/injectionKeys.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/renderer/core/layout/transform/TransformPane.vue

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@
1717

1818
<script setup lang="ts">
1919
import { useRafFn } from '@vueuse/core'
20-
import { computed, provide } from 'vue'
20+
import { computed } from 'vue'
2121
2222
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
23-
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
2423
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
2524
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
2625
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -32,14 +31,7 @@ interface TransformPaneProps {
3231
3332
const props = defineProps<TransformPaneProps>()
3433
35-
const {
36-
camera,
37-
transformStyle,
38-
syncWithCanvas,
39-
canvasToScreen,
40-
screenToCanvas,
41-
isNodeInViewport
42-
} = useTransformState()
34+
const { camera, transformStyle, syncWithCanvas } = useTransformState()
4335
4436
const { isLOD } = useLOD(camera)
4537
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
4840
settleDelay: 512
4941
})
5042
51-
provide(TransformStateKey, {
52-
camera,
53-
canvasToScreen,
54-
screenToCanvas,
55-
isNodeInViewport
56-
})
57-
5843
const emit = defineEmits<{
5944
transformUpdate: []
6045
}>()

src/renderer/core/layout/transform/useTransformState.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import { computed, reactive, readonly } from 'vue'
5353

5454
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
55+
import { createSharedComposable } from '@vueuse/core'
5556

5657
interface Point {
5758
x: number
@@ -64,7 +65,7 @@ interface Camera {
6465
z: number // scale/zoom
6566
}
6667

67-
export const useTransformState = () => {
68+
function useTransformStateIndividual() {
6869
// Reactive state mirroring LiteGraph's canvas transform
6970
const camera = reactive<Camera>({
7071
x: 0,
@@ -91,7 +92,7 @@ export const useTransformState = () => {
9192
*
9293
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
9394
*/
94-
const syncWithCanvas = (canvas: LGraphCanvas) => {
95+
function syncWithCanvas(canvas: LGraphCanvas) {
9596
if (!canvas || !canvas.ds) return
9697

9798
// Mirror LiteGraph's transform state to Vue's reactive state
@@ -112,7 +113,7 @@ export const useTransformState = () => {
112113
* @param point - Point in canvas coordinate system
113114
* @returns Point in screen coordinate system
114115
*/
115-
const canvasToScreen = (point: Point): Point => {
116+
function canvasToScreen(point: Point): Point {
116117
return {
117118
x: (point.x + camera.x) * camera.z,
118119
y: (point.y + camera.y) * camera.z
@@ -138,10 +139,10 @@ export const useTransformState = () => {
138139
}
139140

140141
// Get node's screen bounds for culling
141-
const getNodeScreenBounds = (
142-
pos: ArrayLike<number>,
143-
size: ArrayLike<number>
144-
): DOMRect => {
142+
function getNodeScreenBounds(
143+
pos: [number, number],
144+
size: [number, number]
145+
): DOMRect {
145146
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
146147
const width = size[0] * camera.z
147148
const height = size[1] * camera.z
@@ -150,23 +151,23 @@ export const useTransformState = () => {
150151
}
151152

152153
// Helper: Calculate zoom-adjusted margin for viewport culling
153-
const calculateAdjustedMargin = (baseMargin: number): number => {
154+
function calculateAdjustedMargin(baseMargin: number): number {
154155
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
155156
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
156157
return baseMargin
157158
}
158159

159160
// Helper: Check if node is too small to be visible at current zoom
160-
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
161+
function isNodeTooSmall(nodeSize: [number, number]): boolean {
161162
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
162163
return nodeScreenSize < 4
163164
}
164165

165166
// Helper: Calculate expanded viewport bounds with margin
166-
const getExpandedViewportBounds = (
167+
function getExpandedViewportBounds(
167168
viewport: { width: number; height: number },
168169
margin: number
169-
) => {
170+
) {
170171
const marginX = viewport.width * margin
171172
const marginY = viewport.height * margin
172173
return {
@@ -178,11 +179,11 @@ export const useTransformState = () => {
178179
}
179180

180181
// Helper: Test if node intersects with viewport bounds
181-
const testViewportIntersection = (
182+
function testViewportIntersection(
182183
screenPos: { x: number; y: number },
183-
nodeSize: ArrayLike<number>,
184+
nodeSize: [number, number],
184185
bounds: { left: number; right: number; top: number; bottom: number }
185-
): boolean => {
186+
): boolean {
186187
const nodeRight = screenPos.x + nodeSize[0] * camera.z
187188
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
188189

@@ -195,12 +196,12 @@ export const useTransformState = () => {
195196
}
196197

197198
// Check if node is within viewport with frustum and size-based culling
198-
const isNodeInViewport = (
199-
nodePos: ArrayLike<number>,
200-
nodeSize: ArrayLike<number>,
199+
function isNodeInViewport(
200+
nodePos: [number, number],
201+
nodeSize: [number, number],
201202
viewport: { width: number; height: number },
202203
margin: number = 0.2
203-
): boolean => {
204+
): boolean {
204205
// Early exit for tiny nodes
205206
if (isNodeTooSmall(nodeSize)) return false
206207

@@ -212,10 +213,10 @@ export const useTransformState = () => {
212213
}
213214

214215
// Get viewport bounds in canvas coordinates (for spatial index queries)
215-
const getViewportBounds = (
216+
function getViewportBounds(
216217
viewport: { width: number; height: number },
217218
margin: number = 0.2
218-
) => {
219+
) {
219220
const marginX = viewport.width * margin
220221
const marginY = viewport.height * margin
221222

@@ -244,3 +245,7 @@ export const useTransformState = () => {
244245
getViewportBounds
245246
}
246247
}
248+
249+
export const useTransformState = createSharedComposable(
250+
useTransformStateIndividual
251+
)

src/renderer/core/spatial/boundsCalculator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ interface SpatialBounds {
1111
height: number
1212
}
1313

14-
interface PositionedNode {
15-
pos: ArrayLike<number>
16-
size: ArrayLike<number>
14+
export interface PositionedNode {
15+
pos: [number, number]
16+
size: [number, number]
1717
}
1818

1919
/**

src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LGraph } from '@/lib/litegraph/src/litegraph'
22
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
3+
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
34

45
import type {
56
IMinimapDataSource,
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
2930
}
3031

3132
// Convert MinimapNodeData to the format expected by calculateNodeBounds
32-
const compatibleNodes = nodes.map((node) => ({
33-
pos: [node.x, node.y],
34-
size: [node.width, node.height]
35-
}))
33+
const compatibleNodes = nodes.map(
34+
(node): PositionedNode => ({
35+
pos: [node.x, node.y],
36+
size: [node.width, node.height]
37+
})
38+
)
3639

3740
const bounds = calculateNodeBounds(compatibleNodes)
3841
if (!bounds) {

0 commit comments

Comments
 (0)