feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
This commit is contained in:
Christian Byrne
2025-08-16 20:49:17 -07:00
committed by Benjamin Lu
parent 8df41ab040
commit c773230b21
25 changed files with 3506 additions and 16 deletions

View File

@@ -0,0 +1,211 @@
# Graph Composables - Reactive Layout System
This directory contains composables for the reactive layout system, enabling Vue nodes to handle their own interactions while maintaining synchronization with LiteGraph.
## Composable Architecture
```mermaid
graph TB
subgraph "Composables"
URL[useReactiveLayout<br/>- Singleton Management<br/>- Service Access]
UVNI[useVueNodeInteraction<br/>- Node Dragging<br/>- CSS Transforms]
ULGS[useLiteGraphSync<br/>- Bidirectional Sync<br/>- Position Updates]
end
subgraph "Services"
LT[ReactiveLayoutTree]
HT[ReactiveHitTester]
end
subgraph "Components"
GC[GraphCanvas]
VN[Vue Nodes]
TP[TransformPane]
end
URL --> LT
URL --> HT
UVNI --> URL
ULGS --> URL
GC --> ULGS
VN --> UVNI
TP --> URL
</mermaid>
## Interaction Flow
```mermaid
sequenceDiagram
participant User
participant VueNode
participant UVNI as useVueNodeInteraction
participant LT as LayoutTree
participant LG as LiteGraph
User->>VueNode: pointerdown
VueNode->>UVNI: startDrag(event)
UVNI->>UVNI: Set drag state
UVNI->>UVNI: Capture pointer
User->>VueNode: pointermove
VueNode->>UVNI: handleDrag(event)
UVNI->>UVNI: Calculate delta
UVNI->>VueNode: Update CSS transform
Note over VueNode: Visual feedback only
User->>VueNode: pointerup
VueNode->>UVNI: endDrag(event)
UVNI->>LT: updateNodePosition(finalPos)
LT->>LG: Trigger reactive sync
LG->>LG: Update canvas
```
## useReactiveLayout
Singleton management for the reactive layout system.
```mermaid
classDiagram
class useReactiveLayout {
+layoutTree: ComputedRef~ReactiveLayoutTree~
+hitTester: ComputedRef~ReactiveHitTester~
+nodePositions: ComputedRef~Map~
+nodeBounds: ComputedRef~Map~
+selectedNodes: ComputedRef~Set~
-initialize(): void
}
class Singleton {
<<pattern>>
Shared across all components
}
useReactiveLayout --> Singleton : implements
```
## useVueNodeInteraction
Handles individual node interactions with CSS transforms.
```mermaid
flowchart LR
subgraph "Drag State"
DS[isDragging<br/>dragDelta<br/>dragStartPos]
end
subgraph "Event Handlers"
SD[startDrag]
HD[handleDrag]
ED[endDrag]
end
subgraph "Computed Styles"
NS[nodeStyle<br/>- position<br/>- dimensions<br/>- z-index]
DGS[dragStyle<br/>- transform<br/>- transition]
end
SD --> DS
HD --> DS
ED --> DS
DS --> NS
DS --> DGS
```
### Transform Calculation
```mermaid
graph TB
subgraph "Mouse Delta"
MD[event.clientX/Y - startMouse]
end
subgraph "Canvas Transform"
CT[screenToCanvas conversion]
end
subgraph "Drag Delta"
DD[Canvas-space delta]
end
subgraph "CSS Transform"
CSS[translate(deltaX, deltaY)]
end
MD --> CT
CT --> DD
DD --> CSS
```
## useLiteGraphSync
Bidirectional synchronization between LiteGraph and the reactive layout tree.
```mermaid
stateDiagram-v2
[*] --> Initialize
Initialize --> SyncFromLiteGraph
SyncFromLiteGraph --> WatchLayoutTree
state WatchLayoutTree {
[*] --> Listening
Listening --> PositionChanged: Layout tree update
PositionChanged --> UpdateLiteGraph
UpdateLiteGraph --> TriggerRedraw
TriggerRedraw --> Listening
}
state SyncFromLiteGraph {
[*] --> ReadNodes
ReadNodes --> UpdateLayoutTree
UpdateLayoutTree --> [*]
}
```
## Integration Example
```typescript
// In GraphCanvas.vue
const { initializeSync } = useLiteGraphSync()
onMounted(() => {
initializeSync() // Start bidirectional sync
})
// In LGraphNode.vue
const {
isDragging,
startDrag,
handleDrag,
endDrag,
dragStyle,
updatePosition
} = useVueNodeInteraction(props.nodeData.id)
// Template
<div
:style="[
{
transform: `translate(${position.x}px, ${position.y}px)`,
// ... other styles
},
dragStyle // Applied during drag
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
```
## Performance Considerations
1. **CSS Transforms During Drag**: No layout recalculation, GPU accelerated
2. **Batch Position Updates**: Layout tree updates trigger single LiteGraph sync
3. **Reactive Efficiency**: Vue's computed properties cache results
4. **Spatial Indexing**: QuadTree integration for fast hit testing
## Future Migration Path
Currently: Vue nodes use CSS transforms, commit to layout tree on drag end
Future: Each renderer owns complete interaction handling and layout state

View File

@@ -4,6 +4,7 @@
*/
import { nextTick, reactive, readonly } from 'vue'
import { layoutMutations } from '@/services/layoutMutations'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
@@ -482,6 +483,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
@@ -499,6 +505,14 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void layoutMutations.resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
@@ -549,6 +563,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
layoutMutations.setSource('canvas')
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
@@ -606,6 +623,15 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
}
spatialIndex.insert(id, bounds, id)
// Add node to layout store
layoutMutations.setSource('canvas')
void layoutMutations.createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
@@ -624,6 +650,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Remove from spatial index
spatialIndex.remove(id)
// Remove node from layout store
layoutMutations.setSource('canvas')
void layoutMutations.deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)

View File

@@ -0,0 +1,31 @@
/**
* Main composable for accessing the layout system
*
* Provides unified access to the layout store and mutation API.
*/
import { layoutMutations } from '@/services/layoutMutations'
import { layoutStore } from '@/stores/layoutStore'
import type { Bounds, NodeId, Point } from '@/types/layoutTypes'
/**
* Main composable for accessing the layout system
*/
export function useLayout() {
return {
// Store access
store: layoutStore,
// Mutation API
mutations: layoutMutations,
// Reactive accessors
getNodeLayoutRef: (nodeId: NodeId) => layoutStore.getNodeLayoutRef(nodeId),
getAllNodes: () => layoutStore.getAllNodes(),
getNodesInBounds: (bounds: Bounds) => layoutStore.getNodesInBounds(bounds),
// Non-reactive queries (for performance)
queryNodeAtPoint: (point: Point) => layoutStore.queryNodeAtPoint(point),
queryNodesInBounds: (bounds: Bounds) =>
layoutStore.queryNodesInBounds(bounds)
}
}

View File

@@ -0,0 +1,97 @@
/**
* Composable for syncing LiteGraph with the Layout system
*
* Implements one-way sync from Layout Store to LiteGraph.
* The layout store is the single source of truth.
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import { layoutStore } from '@/stores/layoutStore'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for syncing LiteGraph with the Layout system
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
let unsubscribe: (() => void) | null = null
/**
* Start syncing from Layout system to LiteGraph
* This is one-way: Layout → LiteGraph only
*/
function startSync(canvas: any) {
if (!canvas?.graph) return
// Subscribe to layout changes
unsubscribe = layoutStore.onChange((change) => {
logger.debug('Layout sync received change:', {
source: change.source,
nodeIds: change.nodeIds,
type: change.type
})
// Apply changes to LiteGraph regardless of source
// The layout store is the single source of truth
for (const nodeId of change.nodeIds) {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
if (!liteNode) continue
// Update position if changed
if (
liteNode.pos[0] !== layout.position.x ||
liteNode.pos[1] !== layout.position.y
) {
logger.debug(`Updating LiteGraph node ${nodeId} position:`, {
from: { x: liteNode.pos[0], y: liteNode.pos[1] },
to: layout.position
})
liteNode.pos[0] = layout.position.x
liteNode.pos[1] = layout.position.y
}
// Update size if changed
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
) {
liteNode.size[0] = layout.size.width
liteNode.size[1] = layout.size.height
}
}
// Trigger single redraw for all changes
canvas.setDirty(true, true)
})
}
/**
* Stop syncing
*/
function stopSync() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stopSync()
})
return {
startSync,
stopSync
}
}

View File

@@ -0,0 +1,180 @@
/**
* Node Change Detection
*
* RAF-based change detection for node positions and sizes.
* Syncs LiteGraph changes to the layout system.
*/
import { reactive } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutMutations } from '@/services/layoutMutations'
export interface ChangeDetectionMetrics {
updateTime: number
positionUpdates: number
sizeUpdates: number
rafUpdateCount: number
}
/**
* Change detection for node geometry
*/
export function useNodeChangeDetection(graph: LGraph) {
const metrics = reactive<ChangeDetectionMetrics>({
updateTime: 0,
positionUpdates: 0,
sizeUpdates: 0,
rafUpdateCount: 0
})
// Track last known positions/sizes
const lastSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
/**
* Detects position changes for a single node
*/
const detectPositionChanges = (
node: LGraphNode,
nodePositions: Map<string, { x: number; y: number }>
): boolean => {
const id = String(node.id)
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node
*/
const detectSizeChanges = (
node: LGraphNode,
nodeSizes: Map<string, { width: number; height: number }>
): boolean => {
const id = String(node.id)
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
void layoutMutations.resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Main RAF change detection function
*/
const detectChanges = (
nodePositions: Map<string, { x: number; y: number }>,
nodeSizes: Map<string, { width: number; height: number }>,
onSpatialChange?: (node: LGraphNode, id: string) => void
) => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
layoutMutations.setSource('canvas')
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, nodePositions)
const sizeChanged = detectSizeChanges(node, nodeSizes)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Notify spatial change if needed
if ((posChanged || sizeChanged) && onSpatialChange) {
onSpatialChange(node, id)
}
}
// Update metrics
const endTime = performance.now()
metrics.updateTime = endTime - startTime
metrics.positionUpdates = positionUpdates
metrics.sizeUpdates = sizeUpdates
if (positionUpdates > 0 || sizeUpdates > 0) {
metrics.rafUpdateCount++
}
}
/**
* Take a snapshot of current node positions/sizes
*/
const takeSnapshot = () => {
if (!graph?._nodes) return
lastSnapshot.clear()
for (const node of graph._nodes) {
lastSnapshot.set(String(node.id), {
pos: [node.pos[0], node.pos[1]],
size: [node.size[0], node.size[1]]
})
}
}
/**
* Check if any nodes have changed since last snapshot
*/
const hasChangedSinceSnapshot = (): boolean => {
if (!graph?._nodes) return false
for (const node of graph._nodes) {
const id = String(node.id)
const last = lastSnapshot.get(id)
if (!last) continue
if (
last.pos[0] !== node.pos[0] ||
last.pos[1] !== node.pos[1] ||
last.size[0] !== node.size[0] ||
last.size[1] !== node.size[1]
) {
return true
}
}
return false
}
return {
metrics,
detectChanges,
detectPositionChanges,
detectSizeChanges,
takeSnapshot,
hasChangedSinceSnapshot
}
}

View File

@@ -0,0 +1,199 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import log from 'loglevel'
import { computed, inject } from 'vue'
import { layoutMutations } from '@/services/layoutMutations'
import { layoutStore } from '@/stores/layoutStore'
import type { Point } from '@/types/layoutTypes'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = layoutMutations
// Get transform utilities from TransformPane if available
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
logger.debug(`useNodeLayout initialized for node ${nodeId}`, {
hasLayout: !!layoutRef.value,
initialPosition: layoutRef.value?.position
})
// Computed properties for easy access
const position = computed(() => {
const layout = layoutRef.value
const pos = layout?.position ?? { x: 0, y: 0 }
logger.debug(`Node ${nodeId} position computed:`, {
pos,
hasLayout: !!layout,
layoutRefValue: layout
})
return pos
})
const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 }
)
const bounds = computed(
() =>
layoutRef.value?.bounds ?? {
x: position.value.x,
y: position.value.y,
width: size.value.width,
height: size.value.height
}
)
const isVisible = computed(() => layoutRef.value?.visible ?? true)
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
// Drag state
let isDragging = false
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
/**
* Start dragging the node
*/
function startDrag(event: PointerEvent) {
if (!layoutRef.value) return
isDragging = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
// Set mutation source
mutations.setSource('vue')
// Capture pointer
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
}
/**
* Handle drag movement
*/
const handleDrag = (event: PointerEvent) => {
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
logger.debug(`Drag skipped for node ${nodeId}:`, {
isDragging,
hasDragStartPos: !!dragStartPos,
hasDragStartMouse: !!dragStartMouse,
hasTransformState: !!transformState
})
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
logger.debug(`Dragging node ${nodeId}:`, {
mouseDelta,
canvasDelta,
newPosition,
currentLayoutPos: layoutRef.value?.position
})
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
}
/**
* End dragging
*/
function endDrag(event: PointerEvent) {
if (!isDragging) return
isDragging = false
dragStartPos = null
dragStartMouse = null
// Release pointer
const target = event.target as HTMLElement
target.releasePointerCapture(event.pointerId)
}
/**
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
mutations.setSource('vue')
mutations.moveNode(nodeId, position)
}
/**
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource('vue')
mutations.resizeNode(nodeId, newSize)
}
return {
// Reactive state (via customRef)
layoutRef,
position,
size,
bounds,
isVisible,
zIndex,
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,
handleDrag,
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(() => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
}))
}
}

View File

@@ -0,0 +1,260 @@
/**
* Node State Management
*
* Manages node visibility, dirty state, and other UI state.
* Provides reactive state for Vue components.
*/
import { nextTick, reactive, readonly } from 'vue'
import { PERFORMANCE_CONFIG } from '@/constants/layout'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SafeWidgetData, VueNodeData, WidgetValue } from './useNodeWidgets'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
export interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
}
/**
* Extract safe Vue data from LiteGraph node
*/
export function extractVueNodeData(
node: LGraphNode,
widgets?: SafeWidgetData[]
): VueNodeData {
return {
id: String(node.id),
title: node.title || 'Untitled',
type: node.type || 'Unknown',
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
widgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
}
}
/**
* Node state management composable
*/
export function useNodeState() {
// Reactive state maps
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive node references
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy metadata that auto-GCs
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
/**
* Attach metadata to a node
*/
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high'
})
}
/**
* Get access to original LiteGraph node
*/
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
/**
* Schedule an update for a node
*/
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(
() => flush(),
PERFORMANCE_CONFIG.BATCH_UPDATE_DELAY
)
}
}
}
/**
* Flush all pending updates
*/
const flush = () => {
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Trigger any additional update logic here
}
/**
* Initialize node state
*/
const initializeNode = (node: LGraphNode, vueData: VueNodeData): void => {
const id = String(node.id)
// Store references
nodeRefs.set(id, node)
vueNodeData.set(id, vueData)
// Initialize state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
// Initialize position and size
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Attach metadata
attachMetadata(node)
}
/**
* Clean up node state
*/
const cleanupNode = (nodeId: string): void => {
nodeRefs.delete(nodeId)
vueNodeData.delete(nodeId)
nodeState.delete(nodeId)
nodePositions.delete(nodeId)
nodeSizes.delete(nodeId)
}
/**
* Update node property
*/
const updateNodeProperty = (
nodeId: string,
property: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData) return
if (property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(value)
})
} else if (property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(value)
}
})
}
}
/**
* Update widget state
*/
const updateWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: value as WidgetValue } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
}
return {
// State maps (read-only)
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
nodePositions: readonly(nodePositions) as ReadonlyMap<
string,
{ x: number; y: number }
>,
nodeSizes: readonly(nodeSizes) as ReadonlyMap<
string,
{ width: number; height: number }
>,
// Methods
getNode,
attachMetadata,
scheduleUpdate,
flush,
initializeNode,
cleanupNode,
updateNodeProperty,
updateWidgetState,
// Mutable access for internal use
_mutableNodePositions: nodePositions,
_mutableNodeSizes: nodeSizes
}
}

View File

@@ -0,0 +1,182 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function validateWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
return {
name: widget.name,
type: widget.type,
value: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}