mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 22:39:39 +00:00
add dom element resize observer registry for vue node components
This commit is contained in:
@@ -3,8 +3,10 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
@@ -34,17 +36,30 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
if (!bounds) {
|
||||
return
|
||||
// Get bounds from layout store for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
for (const item of selectableItems) {
|
||||
if (typeof item.id === 'string') {
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [xBase, y, width] = bounds
|
||||
// Compute union bounds
|
||||
const unionBounds = computeUnionBounds(allBounds)
|
||||
if (!unionBounds) return
|
||||
|
||||
worldPosition.value = {
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
y: unionBounds.y
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
|
||||
@@ -1425,6 +1425,33 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
getStateAsUpdate(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.ydoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update node bounds using Yjs transaction for atomicity.
|
||||
*/
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
this.ydoc.transact(() => {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
}
|
||||
}, this.currentActor)
|
||||
|
||||
// Restore original source
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -320,4 +320,9 @@ export interface LayoutStore {
|
||||
setActor(actor: string): void
|
||||
getCurrentSource(): LayoutSource
|
||||
getCurrentActor(): string
|
||||
|
||||
// Batch updates
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -153,6 +154,8 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
useVueElementTracking(props.nodeData.id, 'node')
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||
if (!selectedNodeIds) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Generic Vue Element Tracking System
|
||||
*
|
||||
* Automatically tracks DOM size and position changes for Vue-rendered elements
|
||||
* and syncs them to the layout store. Uses a single shared ResizeObserver for
|
||||
* performance, with elements identified by configurable data attributes.
|
||||
*
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Configuration for different types of tracked elements
|
||||
*/
|
||||
interface ElementTrackingConfig {
|
||||
/** Data attribute name (e.g., 'nodeId') */
|
||||
dataAttribute: string
|
||||
/** Handler for processing bounds updates */
|
||||
updateHandler: (updates: Array<{ id: string; bounds: Bounds }>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tracking configurations by element type
|
||||
*/
|
||||
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
[
|
||||
'node',
|
||||
{
|
||||
dataAttribute: 'nodeId',
|
||||
updateHandler: (updates) => {
|
||||
const nodeUpdates = updates.map(({ id, bounds }) => ({
|
||||
nodeId: id as NodeId,
|
||||
bounds
|
||||
}))
|
||||
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Group updates by element type
|
||||
const updatesByType = new Map<string, Array<{ id: string; bounds: Bounds }>>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
const element = entry.target
|
||||
|
||||
// Find which type this element belongs to
|
||||
let elementType: string | undefined
|
||||
let elementId: string | undefined
|
||||
|
||||
for (const [type, config] of trackingConfigs) {
|
||||
const id = element.dataset[config.dataAttribute]
|
||||
if (id) {
|
||||
elementType = type
|
||||
elementId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!elementType || !elementId) continue
|
||||
|
||||
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
const bounds: Bounds = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width,
|
||||
height
|
||||
}
|
||||
|
||||
if (!updatesByType.has(elementType)) {
|
||||
updatesByType.set(elementType, [])
|
||||
}
|
||||
const updates = updatesByType.get(elementType)
|
||||
if (updates) {
|
||||
updates.push({ id: elementId, bounds })
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates by type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length > 0) {
|
||||
config.updateHandler(updates)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
|
||||
*
|
||||
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
|
||||
* when unmounted. The tracked element is identified by a data attribute set on the
|
||||
* component's root DOM element.
|
||||
*
|
||||
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
|
||||
* Example: node ID like 'node-123', widget ID like 'widget-456'
|
||||
* @param trackingType - Type of element being tracked, determines which tracking config to use
|
||||
* Example: 'node' for Vue nodes, 'widget' for UI widgets
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Track a Vue node component with ID 'my-node-123'
|
||||
* useVueElementTracking('my-node-123', 'node')
|
||||
*
|
||||
* // Would set data-node-id="my-node-123" on the component's root element
|
||||
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Set the appropriate data attribute
|
||||
element.dataset[config.dataAttribute] = appIdentifier
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Remove the data attribute
|
||||
delete element.dataset[config.dataAttribute]
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Finds the greatest common divisor (GCD) for two numbers.
|
||||
*
|
||||
@@ -5,12 +8,12 @@
|
||||
* @param b - The second number.
|
||||
* @returns The GCD of the two numbers.
|
||||
*/
|
||||
const gcd = (a: number, b: number): number => {
|
||||
export const gcd = (a: number, b: number): number => {
|
||||
return b === 0 ? a : gcd(b, a % b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the least common multiple (LCM) for two numbers.
|
||||
* Finds the export least common multiple (LCM) for two numbers.
|
||||
*
|
||||
* @param a - The first number.
|
||||
* @param b - The second number.
|
||||
@@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => {
|
||||
export const lcm = (a: number, b: number): number => {
|
||||
return Math.abs(a * b) / gcd(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the union (bounding box) of multiple rectangles using a single-pass algorithm.
|
||||
*
|
||||
* Finds the minimum and maximum x/y coordinates across all rectangles to create
|
||||
* a single bounding rectangle that contains all input rectangles. Optimized for
|
||||
* performance with V8-friendly tuple access patterns.
|
||||
*
|
||||
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
|
||||
* @returns Bounds object with union rectangle, or null if no rectangles provided
|
||||
*/
|
||||
export function computeUnionBounds(
|
||||
rectangles: readonly ReadOnlyRect[]
|
||||
): Bounds | null {
|
||||
const n = rectangles.length
|
||||
if (n === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const r0 = rectangles[0]
|
||||
let minX = r0[0]
|
||||
let minY = r0[1]
|
||||
let maxX = minX + r0[2]
|
||||
let maxY = minY + r0[3]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
const r = rectangles[i]
|
||||
const x1 = r[0]
|
||||
const y1 = r[1]
|
||||
const x2 = x1 + r[2]
|
||||
const y2 = y1 + r[3]
|
||||
|
||||
if (x1 < minX) minX = x1
|
||||
if (y1 < minY) minY = y1
|
||||
if (x2 > maxX) maxX = x2
|
||||
if (y2 > maxY) maxY = y2
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user