mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
This pull request improves the selection and movement logic for groups and nodes on the LiteGraph canvas, especially when using Vue-based node rendering. The most notable changes are the addition of proper bounding box handling for groups and a new coordinated movement mechanism that updates both LiteGraph internals and the Vue layout store when dragging nodes and groups. **Selection and bounding box calculation:** * Added support for including `LGraphGroup` bounding rectangles when calculating the selection toolbox position, so groups are now properly considered in selection overlays. [[1]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713L8-R8) [[2]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713R95-R97) **Node and group movement synchronization (Vue nodes mode):** * Introduced a new movement logic in `LGraphCanvas` for Vue nodes mode: when dragging, groups and their child nodes are moved together, and all affected node positions are batch-updated in both LiteGraph and the Vue layout store via `moveNode`. This ensures canvas and UI stay in sync. * Added imports for layout mutation operations and types to support the above synchronization. These changes make group selection and movement more robust and ensure that UI and internal state remain consistent when using the Vue-based node system. https://github.com/user-attachments/assets/153792dc-08f2-4b53-b2bf-b0591ee76559 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5886-Move-Frame-Vue-Nodes-2806d73d365081e48b5ef96d6c6b6d6b) by [Unito](https://www.unito.io)
257 lines
7.7 KiB
TypeScript
257 lines
7.7 KiB
TypeScript
import { useRafFn } from '@vueuse/core'
|
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
|
|
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
|
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
|
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
|
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
|
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
|
import { computeUnionBounds } from '@/utils/mathUtil'
|
|
|
|
/**
|
|
* Manages the position of the selection toolbox independently.
|
|
* Uses CSS custom properties for performant transform updates.
|
|
*/
|
|
|
|
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
|
|
export const moreOptionsOpen = ref(false)
|
|
export const forceCloseMoreOptionsSignal = ref(0)
|
|
export const restoreMoreOptionsSignal = ref(0)
|
|
export const moreOptionsRestorePending = ref(false)
|
|
let moreOptionsWasOpenBeforeDrag = false
|
|
let moreOptionsSelectionSignature: string | null = null
|
|
|
|
function buildSelectionSignature(
|
|
store: ReturnType<typeof useCanvasStore>
|
|
): string | null {
|
|
const c = store.canvas
|
|
if (!c) return null
|
|
const items = Array.from(c.selectedItems)
|
|
if (items.length !== 1) return null
|
|
const item = items[0]
|
|
if (isLGraphNode(item)) return `N:${item.id}`
|
|
if (isLGraphGroup(item)) return `G:${item.id}`
|
|
return null
|
|
}
|
|
|
|
function currentSelectionMatchesSignature(
|
|
store: ReturnType<typeof useCanvasStore>
|
|
) {
|
|
if (!moreOptionsSelectionSignature) return false
|
|
return buildSelectionSignature(store) === moreOptionsSelectionSignature
|
|
}
|
|
|
|
export function useSelectionToolboxPosition(
|
|
toolboxRef: Ref<HTMLElement | undefined>
|
|
) {
|
|
const canvasStore = useCanvasStore()
|
|
const lgCanvas = canvasStore.getCanvas()
|
|
const { getSelectableItems } = useSelectedLiteGraphItems()
|
|
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
|
|
|
// World position of selection center
|
|
const worldPosition = ref({ x: 0, y: 0 })
|
|
|
|
const visible = ref(false)
|
|
|
|
/**
|
|
* Update position based on selection
|
|
*/
|
|
const updateSelectionBounds = () => {
|
|
const selectableItems = getSelectableItems()
|
|
|
|
if (!selectableItems.size) {
|
|
visible.value = false
|
|
return
|
|
}
|
|
|
|
visible.value = true
|
|
|
|
// Get bounds for all selected items
|
|
const allBounds: ReadOnlyRect[] = []
|
|
for (const item of selectableItems) {
|
|
// Skip items without valid IDs
|
|
if (item.id == null) continue
|
|
|
|
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
|
// Use layout store for Vue nodes (only works with string IDs)
|
|
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
|
if (layout) {
|
|
allBounds.push([
|
|
layout.bounds.x,
|
|
layout.bounds.y,
|
|
layout.bounds.width,
|
|
layout.bounds.height
|
|
])
|
|
}
|
|
} else {
|
|
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
|
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
|
|
const bounds = item.getBounding()
|
|
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute union bounds
|
|
const unionBounds = computeUnionBounds(allBounds)
|
|
if (!unionBounds) return
|
|
|
|
worldPosition.value = {
|
|
x: unionBounds.x + unionBounds.width / 2,
|
|
// createBounds() applied a default padding of 10px
|
|
// so adjust Y to maintain visual consistency
|
|
y: unionBounds.y - 10
|
|
}
|
|
|
|
updateTransform()
|
|
}
|
|
|
|
const updateTransform = () => {
|
|
if (!visible.value) return
|
|
|
|
const { scale, offset } = lgCanvas.ds
|
|
const canvasRect = lgCanvas.canvas.getBoundingClientRect()
|
|
|
|
const screenX =
|
|
(worldPosition.value.x + offset[0]) * scale + canvasRect.left
|
|
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasRect.top
|
|
|
|
// Update CSS custom properties directly for best performance
|
|
if (toolboxRef.value) {
|
|
toolboxRef.value.style.setProperty('--tb-x', `${screenX}px`)
|
|
toolboxRef.value.style.setProperty('--tb-y', `${screenY}px`)
|
|
}
|
|
}
|
|
|
|
// Sync with canvas transform
|
|
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
|
|
|
|
// Watch for selection changes
|
|
watch(
|
|
() => canvasStore.getCanvas().state.selectionChanged,
|
|
(changed) => {
|
|
if (changed) {
|
|
if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) {
|
|
moreOptionsRestorePending.value = false
|
|
moreOptionsWasOpenBeforeDrag = false
|
|
if (!moreOptionsOpen.value) {
|
|
moreOptionsSelectionSignature = null
|
|
} else {
|
|
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
|
}
|
|
}
|
|
updateSelectionBounds()
|
|
canvasStore.getCanvas().state.selectionChanged = false
|
|
if (visible.value) {
|
|
startSync()
|
|
} else {
|
|
stopSync()
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
watch(
|
|
() => moreOptionsOpen.value,
|
|
(v) => {
|
|
if (v) {
|
|
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
|
} else if (!canvasStore.canvas?.state?.draggingItems) {
|
|
moreOptionsSelectionSignature = null
|
|
if (moreOptionsRestorePending.value)
|
|
moreOptionsRestorePending.value = false
|
|
}
|
|
}
|
|
)
|
|
|
|
const handleDragStateChange = (dragging: boolean) => {
|
|
if (dragging) {
|
|
handleDragStart()
|
|
return
|
|
}
|
|
|
|
handleDragEnd()
|
|
}
|
|
|
|
const handleDragStart = () => {
|
|
visible.value = false
|
|
|
|
// Early return if more options wasn't open
|
|
if (!moreOptionsOpen.value) {
|
|
moreOptionsRestorePending.value = false
|
|
moreOptionsWasOpenBeforeDrag = false
|
|
return
|
|
}
|
|
|
|
// Handle more options cleanup
|
|
const currentSig = buildSelectionSignature(canvasStore)
|
|
const selectionChanged = currentSig !== moreOptionsSelectionSignature
|
|
|
|
if (selectionChanged) {
|
|
moreOptionsSelectionSignature = null
|
|
}
|
|
moreOptionsOpen.value = false
|
|
moreOptionsWasOpenBeforeDrag = true
|
|
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
|
|
|
|
if (moreOptionsRestorePending.value) {
|
|
forceCloseMoreOptionsSignal.value++
|
|
return
|
|
}
|
|
|
|
moreOptionsWasOpenBeforeDrag = false
|
|
}
|
|
|
|
const handleDragEnd = () => {
|
|
requestAnimationFrame(() => {
|
|
updateSelectionBounds()
|
|
|
|
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
|
|
const shouldRestore =
|
|
moreOptionsWasOpenBeforeDrag &&
|
|
visible.value &&
|
|
moreOptionsRestorePending.value &&
|
|
selectionMatches
|
|
|
|
// Single point of assignment for each ref
|
|
moreOptionsRestorePending.value =
|
|
shouldRestore && moreOptionsRestorePending.value
|
|
moreOptionsWasOpenBeforeDrag = false
|
|
|
|
if (shouldRestore) {
|
|
restoreMoreOptionsSignal.value++
|
|
}
|
|
})
|
|
}
|
|
|
|
// Unified dragging state - combines both LiteGraph and Vue node dragging
|
|
const isDragging = computed((): boolean => {
|
|
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
|
|
const vueNodeDragging =
|
|
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
|
|
return litegraphDragging || vueNodeDragging
|
|
})
|
|
|
|
watch(isDragging, handleDragStateChange)
|
|
|
|
onUnmounted(() => {
|
|
resetMoreOptionsState()
|
|
})
|
|
|
|
return {
|
|
visible
|
|
}
|
|
}
|
|
|
|
// External cleanup utility to be called when SelectionToolbox component unmounts
|
|
function resetMoreOptionsState() {
|
|
moreOptionsOpen.value = false
|
|
moreOptionsRestorePending.value = false
|
|
moreOptionsWasOpenBeforeDrag = false
|
|
moreOptionsSelectionSignature = null
|
|
}
|