add dom element resize observer registry for vue node components

This commit is contained in:
bymyself
2025-09-08 13:24:07 -07:00
parent 76bfc9e678
commit 3112dba454
8 changed files with 470 additions and 9 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
}
})
}

View File

@@ -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
}
}