Compare commits

...

10 Commits

Author SHA1 Message Date
Benjamin Lu
c5ade0aaeb Merge remote-tracking branch 'origin/bl-grahhhh' into bl-building-on-top 2025-09-09 10:34:33 -07:00
bymyself
4c42168293 add dom element resize observer registry for vue node components 2025-09-09 10:31:00 -07:00
Benjamin Lu
fe63d89ef2 Revert "[refactor] Use getSlotPosition for Vue nodes in link rendering"
This reverts commit 881ff67fe2.
2025-09-08 19:07:30 -07:00
Benjamin Lu
06469bebed empty commit for ci 2025-09-08 16:46:50 -07:00
Benjamin Lu
0d4ecf801f empty commit for ci 2025-09-08 16:46:36 -07:00
Benjamin Lu
aecbdced12 yagni mm? [skip ci] 2025-09-08 14:38:38 -07:00
Benjamin Lu
8b5c7ffb60 Use update instead 2025-09-08 14:38:38 -07:00
Benjamin Lu
8586e684a6 Fix init issue 2025-09-08 14:38:38 -07:00
Benjamin Lu
cf42355a9f Add offset 2025-09-08 14:38:38 -07:00
Benjamin Lu
881ff67fe2 [refactor] Use getSlotPosition for Vue nodes in link rendering
Replace direct node position calls with getSlotPosition utility when Vue nodes mode is enabled. This ensures consistent slot positioning across the canvas rendering system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 14:38:38 -07:00
10 changed files with 537 additions and 21 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

@@ -0,0 +1,52 @@
/**
* Canvas Rect Cache (VueUse-based)
*
* Tracks the client-origin and size of the graph canvas container using
* useElementBounding, and exposes a small API to read the rect and
* subscribe to changes.
*
* We assume no document scrolling (body is overflow: hidden). Layout
* changes are driven by window resize and container/splitter changes.
*/
import { useElementBounding } from '@vueuse/core'
import { shallowRef, watch } from 'vue'
// Target container element (covers the canvas fully and shares its origin)
const containerRef = shallowRef<HTMLElement | null>(null)
// Bind bounding measurement once; element may be resolved later
const { x, y, width, height, update } = useElementBounding(containerRef, {
// Track layout changes from resize; scrolling is disabled globally
windowResize: true,
windowScroll: false,
immediate: true
})
// Listener registry for external subscribers
const listeners = new Set<() => void>()
function ensureContainer() {
if (!containerRef.value) {
containerRef.value = document.getElementById(
'graph-canvas-container'
) as HTMLElement | null
// Force an immediate measurement once the element is resolved
if (containerRef.value) update()
}
}
// Notify subscribers when the bounding rect changes
watch([x, y, width, height], () => {
if (listeners.size) listeners.forEach((cb) => cb())
})
export function onCanvasRectChange(cb: () => void): () => void {
ensureContainer()
listeners.add(cb)
return () => listeners.delete(cb)
}
export function getCanvasClientOrigin() {
ensureContainer()
return { left: x.value || 0, top: y.value || 0 }
}

View File

@@ -21,6 +21,10 @@ import {
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
getCanvasClientOrigin,
onCanvasRectChange
} from '@/renderer/core/layout/dom/canvasRectCache'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
@@ -52,7 +56,7 @@ const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
unsubscribeRectChange?: () => void
}
>()
@@ -90,6 +94,8 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Normalize to canvas-relative screen coordinates (CSS pixels)
const { left: canvasLeft, top: canvasTop } = getCanvasClientOrigin()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
@@ -106,10 +112,10 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
// Center of the visual connector (dot) in canvas-relative screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
x: rect.left + rect.width / 2 - canvasLeft,
y: rect.top + rect.height / 2 - canvasTop
}
const centerCanvas = transform.screenToCanvas(centerScreen)
@@ -192,12 +198,11 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
// Subscribe to canvas rect changes (covers window resize and layout changes)
const unsubscribe = onCanvasRectChange(() =>
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
)
cleanup.unsubscribeRectChange = unsubscribe
cleanupFunctions.set(elRef, cleanup)
})
@@ -209,9 +214,7 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
if (cleanup.unsubscribeRectChange) cleanup.unsubscribeRectChange()
cleanupFunctions.delete(elRef)
}

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

View File

@@ -0,0 +1,121 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({
useVueElementTracking: vi.fn()
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
startDrag: vi.fn(),
handleDrag: vi.fn(),
endDrag: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: () => ({
lodLevel: { value: 0 },
shouldRenderWidgets: { value: true },
shouldRenderSlots: { value: true },
shouldRenderContent: { value: false },
lodCssClass: { value: '' }
}),
LODLevel: { MINIMAL: 0 }
}))
describe('LGraphNode', () => {
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
return mount(LGraphNode, {
props,
global: {
provide: {
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
}
}
})
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should call resize tracking composable with node ID', async () => {
const { useVueElementTracking } = vi.mocked(
await import(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
)
)
mountLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
})
it('should render with data-node-id attribute', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
})
it('should render node title', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.text()).toContain('Test Node')
})
it('should apply selected styling when selected prop is true', () => {
const wrapper = mountLGraphNode(
{ nodeData: mockNodeData, selected: true },
new Set(['test-node-123'])
)
expect(wrapper.classes()).toContain('border-blue-500')
expect(wrapper.classes()).toContain('ring-2')
expect(wrapper.classes()).toContain('ring-blue-300')
})
it('should apply executing animation when executing prop is true', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
expect(wrapper.classes()).toContain('animate-pulse')
})
it('should emit node-click event on pointer down', async () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerdown')
expect(wrapper.emitted('node-click')).toHaveLength(1)
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2)
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
})
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
describe('mathUtil', () => {
describe('gcd', () => {
it('should compute greatest common divisor correctly', () => {
expect(gcd(48, 18)).toBe(6)
expect(gcd(100, 25)).toBe(25)
expect(gcd(17, 13)).toBe(1)
expect(gcd(0, 5)).toBe(5)
expect(gcd(5, 0)).toBe(5)
})
})
describe('lcm', () => {
it('should compute least common multiple correctly', () => {
expect(lcm(4, 6)).toBe(12)
expect(lcm(15, 20)).toBe(60)
expect(lcm(7, 11)).toBe(77)
})
})
describe('computeUnionBounds', () => {
it('should return null for empty input', () => {
expect(computeUnionBounds([])).toBe(null)
})
// Tests for tuple format (ReadOnlyRect)
it('should work with ReadOnlyRect tuple format', () => {
const tuples: ReadOnlyRect[] = [
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 10, // min(10, 50)
y: 10, // min(20, 10)
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
})
})
it('should handle single ReadOnlyRect tuple', () => {
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
const result = computeUnionBounds([tuple])
expect(result).toEqual({
x: 10,
y: 20,
width: 30,
height: 40
})
})
it('should handle tuple format with negative dimensions', () => {
const tuples: ReadOnlyRect[] = [
[100, 50, -20, -10] as const, // x+width=80, y+height=40
[90, 45, 15, 20] as const // x+width=105, y+height=65
]
const result = computeUnionBounds(tuples)
expect(result).toEqual({
x: 90, // min(100, 90)
y: 45, // min(50, 45)
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
})
})
it('should maintain optimal performance with SoA tuples', () => {
// Test that array access is as expected for typical selection sizes
const tuples: ReadOnlyRect[] = Array.from(
{ length: 10 },
(_, i) =>
[
i * 20, // x
i * 15, // y
100 + i * 5, // width
80 + i * 3 // height
] as const
)
const result = computeUnionBounds(tuples)
expect(result).toBeTruthy()
expect(result!.x).toBe(0)
expect(result!.y).toBe(0)
expect(result!.width).toBe(325)
expect(result!.height).toBe(242)
})
})
})