mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-25 01:04:06 +00:00
[feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Benjamin Lu <benceruleanlu@proton.me> Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
committed by
Benjamin Lu
parent
6e04cb72b0
commit
19084e2799
242
src/composables/element/useTransformState.ts
Normal file
242
src/composables/element/useTransformState.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Composable for managing transform state synchronized with LiteGraph canvas
|
||||
*
|
||||
* This composable is a critical part of the hybrid rendering architecture that
|
||||
* allows Vue components to render in perfect alignment with LiteGraph's canvas.
|
||||
*
|
||||
* ## Core Concept
|
||||
*
|
||||
* LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
|
||||
* Vue components need to render nodes on top of this canvas. The challenge is
|
||||
* synchronizing the coordinate systems:
|
||||
*
|
||||
* - LiteGraph: Uses canvas coordinates with its own transform matrix
|
||||
* - Vue/DOM: Uses screen coordinates with CSS transforms
|
||||
*
|
||||
* ## Solution: Transform Container Pattern
|
||||
*
|
||||
* Instead of transforming individual nodes (O(n) complexity), we:
|
||||
* 1. Mirror LiteGraph's transform matrix to a single CSS container
|
||||
* 2. Place all Vue nodes as children with simple absolute positioning
|
||||
* 3. Achieve O(1) transform updates regardless of node count
|
||||
*
|
||||
* ## Coordinate Systems
|
||||
*
|
||||
* - **Canvas coordinates**: LiteGraph's internal coordinate system
|
||||
* - **Screen coordinates**: Browser's viewport coordinate system
|
||||
* - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - GPU acceleration via CSS transforms
|
||||
* - No layout thrashing (only transform changes)
|
||||
* - Efficient viewport culling calculations
|
||||
* - Scales to 1000+ nodes while maintaining 60 FPS
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { camera, transformStyle, canvasToScreen } = useTransformState()
|
||||
*
|
||||
* // In template
|
||||
* <div :style="transformStyle">
|
||||
* <NodeComponent
|
||||
* v-for="node in nodes"
|
||||
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
* />
|
||||
* </div>
|
||||
*
|
||||
* // Convert coordinates
|
||||
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
|
||||
* ```
|
||||
*/
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
x: number
|
||||
y: number
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1
|
||||
})
|
||||
|
||||
// Computed transform string for CSS
|
||||
const transformStyle = computed(() => ({
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
/**
|
||||
* Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
|
||||
*
|
||||
* Called every frame via RAF to ensure Vue components stay aligned with canvas.
|
||||
* This is the heart of the hybrid rendering system - it bridges the gap between
|
||||
* LiteGraph's canvas transforms and Vue's reactive system.
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
// ds.offset = pan offset, ds.scale = zoom level
|
||||
camera.x = canvas.ds.offset[0]
|
||||
camera.y = canvas.ds.offset[1]
|
||||
camera.z = canvas.ds.scale || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts canvas coordinates to screen coordinates
|
||||
*
|
||||
* Applies the same transform that LiteGraph uses for rendering.
|
||||
* Essential for positioning Vue components to align with canvas elements.
|
||||
*
|
||||
* Formula: screen = canvas * scale + offset
|
||||
*
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
return {
|
||||
x: point.x * camera.z + camera.x,
|
||||
y: point.y * camera.z + camera.y
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
*
|
||||
* Inverse of canvasToScreen. Useful for hit testing and converting
|
||||
* mouse events back to canvas space.
|
||||
*
|
||||
* Formula: canvas = (screen - offset) / scale
|
||||
*
|
||||
* @param point - Point in screen coordinate system
|
||||
* @returns Point in canvas coordinate system
|
||||
*/
|
||||
const screenToCanvas = (point: Point): Point => {
|
||||
return {
|
||||
x: (point.x - camera.x) / camera.z,
|
||||
y: (point.y - camera.y) / camera.z
|
||||
}
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
|
||||
return new DOMRect(topLeft.x, topLeft.y, width, height)
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
left: -marginX,
|
||||
right: viewport.width + marginX,
|
||||
top: -marginY,
|
||||
bottom: viewport.height + marginY
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
return !(
|
||||
nodeRight < bounds.left ||
|
||||
screenPos.x > bounds.right ||
|
||||
nodeBottom < bounds.top ||
|
||||
screenPos.y > bounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
|
||||
const adjustedMargin = calculateAdjustedMargin(margin)
|
||||
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
|
||||
|
||||
return testViewportIntersection(screenPos, nodeSize, bounds)
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
|
||||
const bottomRight = screenToCanvas({
|
||||
x: viewport.width + marginX,
|
||||
y: viewport.height + marginY
|
||||
})
|
||||
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
camera: readonly(camera),
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
getNodeScreenBounds,
|
||||
isNodeInViewport,
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
115
src/composables/graph/useCanvasTransformSync.ts
Normal file
115
src/composables/graph/useCanvasTransformSync.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
* Whether to automatically start syncing when canvas is available
|
||||
* @default true
|
||||
*/
|
||||
autoStart?: boolean
|
||||
}
|
||||
|
||||
export interface CanvasTransformSyncCallbacks {
|
||||
/**
|
||||
* Called when sync starts
|
||||
*/
|
||||
onStart?: () => void
|
||||
/**
|
||||
* Called after each sync update with timing information
|
||||
*/
|
||||
onUpdate?: (duration: number) => void
|
||||
/**
|
||||
* Called when sync stops
|
||||
*/
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
|
||||
*
|
||||
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
|
||||
* on every frame. It handles RAF lifecycle management, provides performance timing,
|
||||
* and ensures proper cleanup.
|
||||
*
|
||||
* The sync function typically reads canvas.ds (draw state) properties like offset and scale
|
||||
* to keep Vue components aligned with the canvas coordinate system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
|
||||
* canvas,
|
||||
* (canvas) => syncWithCanvas(canvas),
|
||||
* {
|
||||
* onStart: () => emit('rafStatusChange', true),
|
||||
* onUpdate: (time) => emit('transformUpdate', time),
|
||||
* onStop: () => emit('rafStatusChange', false)
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function useCanvasTransformSync(
|
||||
canvas: LGraphCanvas | undefined | null,
|
||||
syncFn: (canvas: LGraphCanvas) => void,
|
||||
callbacks: CanvasTransformSyncCallbacks = {},
|
||||
options: CanvasTransformSyncOptions = {}
|
||||
) {
|
||||
const { autoStart = true } = options
|
||||
const { onStart, onUpdate, onStop } = callbacks
|
||||
|
||||
const isActive = ref(false)
|
||||
let rafId: number | null = null
|
||||
|
||||
const startSync = () => {
|
||||
if (isActive.value || !canvas) return
|
||||
|
||||
isActive.value = true
|
||||
onStart?.()
|
||||
|
||||
const sync = () => {
|
||||
if (!isActive.value || !canvas) return
|
||||
|
||||
try {
|
||||
const startTime = performance.now()
|
||||
syncFn(canvas)
|
||||
const endTime = performance.now()
|
||||
|
||||
onUpdate?.(endTime - startTime)
|
||||
} catch (error) {
|
||||
console.warn('Canvas transform sync error:', error)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(sync)
|
||||
}
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
const stopSync = () => {
|
||||
if (!isActive.value) return
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
isActive.value = false
|
||||
onStop?.()
|
||||
}
|
||||
|
||||
// Auto-start if canvas is available and autoStart is enabled
|
||||
if (autoStart && canvas) {
|
||||
startSync()
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
onUnmounted(() => {
|
||||
stopSync()
|
||||
})
|
||||
|
||||
return {
|
||||
isActive,
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
186
src/composables/graph/useEventForwarding.ts
Normal file
186
src/composables/graph/useEventForwarding.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export function useEventForwarding() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Track active drag operation
|
||||
let isDragging = false
|
||||
let dragCleanup: (() => void) | null = null
|
||||
// Store last known position for escape key handling
|
||||
const lastPointerPosition = { x: 0, y: 0 }
|
||||
|
||||
function createSyntheticPointerEvent(
|
||||
originalEvent: PointerEvent,
|
||||
eventType: string
|
||||
): PointerEvent {
|
||||
// Only copy properties that LiteGraph actually uses
|
||||
return new PointerEvent(eventType, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
// Position properties
|
||||
clientX: originalEvent.clientX,
|
||||
clientY: originalEvent.clientY,
|
||||
// Modifier keys
|
||||
ctrlKey: originalEvent.ctrlKey,
|
||||
shiftKey: originalEvent.shiftKey,
|
||||
altKey: originalEvent.altKey,
|
||||
metaKey: originalEvent.metaKey,
|
||||
// Button state
|
||||
button: originalEvent.button,
|
||||
buttons: originalEvent.buttons,
|
||||
// Pointer tracking
|
||||
pointerId: originalEvent.pointerId,
|
||||
isPrimary: originalEvent.isPrimary,
|
||||
pointerType: originalEvent.pointerType
|
||||
})
|
||||
}
|
||||
|
||||
function forwardPointerEvent(
|
||||
originalEvent: PointerEvent,
|
||||
eventType: 'down' | 'move' | 'up'
|
||||
) {
|
||||
const canvas: LGraphCanvas | null = canvasStore.getCanvas()
|
||||
if (!canvas) {
|
||||
console.warn('No canvas available for event forwarding')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent original event from bubbling to canvas
|
||||
originalEvent.stopPropagation()
|
||||
originalEvent.preventDefault()
|
||||
|
||||
// Create synthetic event
|
||||
const syntheticEvent = createSyntheticPointerEvent(
|
||||
originalEvent,
|
||||
`pointer${eventType}`
|
||||
)
|
||||
|
||||
// Create a mutable copy of the event for LiteGraph to modify
|
||||
const mutableEvent = syntheticEvent as PointerEvent & {
|
||||
canvasX?: number
|
||||
canvasY?: number
|
||||
deltaX?: number
|
||||
deltaY?: number
|
||||
safeOffsetX?: number
|
||||
safeOffsetY?: number
|
||||
}
|
||||
|
||||
// Let LiteGraph adjust coordinates to graph space
|
||||
// Using 'as any' to bypass TypeScript assertion limitations
|
||||
;(canvas.adjustMouseEvent as any)(mutableEvent)
|
||||
|
||||
// Forward to appropriate handler
|
||||
switch (eventType) {
|
||||
case 'down':
|
||||
canvas.processMouseDown(mutableEvent)
|
||||
break
|
||||
case 'move':
|
||||
canvas.processMouseMove(mutableEvent)
|
||||
break
|
||||
case 'up':
|
||||
canvas.processMouseUp(mutableEvent)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-create event handlers to avoid recreating on each pointerdown
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
// Update last known position
|
||||
lastPointerPosition.x = e.clientX
|
||||
lastPointerPosition.y = e.clientY
|
||||
forwardPointerEvent(e, 'move')
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
isDragging = false
|
||||
forwardPointerEvent(e, 'up')
|
||||
|
||||
// Clean up listeners
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Handle escape key to cancel drag
|
||||
if (e.key === 'Escape' && isDragging) {
|
||||
isDragging = false
|
||||
|
||||
// Create minimal synthetic cancel event
|
||||
const cancelEvent = new PointerEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
// Use last known position from the current drag operation
|
||||
clientX: lastPointerPosition.x,
|
||||
clientY: lastPointerPosition.y,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
})
|
||||
|
||||
const canvas: LGraphCanvas | null = canvasStore.getCanvas()
|
||||
if (canvas) {
|
||||
const mutableCancelEvent = cancelEvent as PointerEvent & {
|
||||
canvasX?: number
|
||||
canvasY?: number
|
||||
deltaX?: number
|
||||
deltaY?: number
|
||||
safeOffsetX?: number
|
||||
safeOffsetY?: number
|
||||
}
|
||||
;(canvas.adjustMouseEvent as any)(mutableCancelEvent)
|
||||
canvas.processMouseUp(mutableCancelEvent)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotPointerDown(originalEvent: PointerEvent) {
|
||||
// Forward the initial pointer down
|
||||
forwardPointerEvent(originalEvent, 'down')
|
||||
|
||||
// Set up drag handling
|
||||
isDragging = true
|
||||
// Initialize last known position
|
||||
lastPointerPosition.x = originalEvent.clientX
|
||||
lastPointerPosition.y = originalEvent.clientY
|
||||
|
||||
// Add global listeners for drag handling
|
||||
document.addEventListener('pointermove', handlePointerMove, true)
|
||||
document.addEventListener('pointerup', handlePointerUp, true)
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
|
||||
// Store cleanup function
|
||||
dragCleanup = () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove, true)
|
||||
document.removeEventListener('pointerup', handlePointerUp, true)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
function cleanup() {
|
||||
isDragging = false
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSlotPointerDown,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
779
src/composables/graph/useGraphNodeManager.ts
Normal file
779
src/composables/graph/useGraphNodeManager.ts
Normal file
@@ -0,0 +1,779 @@
|
||||
/**
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { nextTick, reactive, readonly } from 'vue'
|
||||
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import { type Bounds, QuadTree } from '../../utils/spatial/QuadTree'
|
||||
|
||||
export interface NodeState {
|
||||
visible: boolean
|
||||
dirty: boolean
|
||||
lastUpdate: number
|
||||
culled: boolean
|
||||
}
|
||||
|
||||
export interface NodeMetadata {
|
||||
lastRenderTime: number
|
||||
cachedBounds: DOMRect | null
|
||||
lodLevel: 'high' | 'medium' | 'low'
|
||||
spatialIndex?: QuadTree<string>
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
fps: number
|
||||
frameTime: number
|
||||
updateTime: number
|
||||
nodeCount: number
|
||||
culledCount: number
|
||||
callbackUpdateCount: number
|
||||
rafUpdateCount: number
|
||||
adaptiveQuality: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpatialMetrics {
|
||||
queryTime: number
|
||||
nodesInIndex: number
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
nodePositions: ReadonlyMap<string, { x: number; y: number }>
|
||||
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
setupEventListeners(): () => void
|
||||
cleanup(): void
|
||||
|
||||
// Update methods
|
||||
scheduleUpdate(
|
||||
nodeId?: string,
|
||||
priority?: 'critical' | 'normal' | 'low'
|
||||
): void
|
||||
forceSync(): void
|
||||
detectChangesInRAF(): void
|
||||
|
||||
// Spatial queries
|
||||
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
|
||||
|
||||
// Performance
|
||||
performanceMetrics: PerformanceMetrics
|
||||
spatialMetrics: SpatialMetrics
|
||||
|
||||
// Debug
|
||||
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
|
||||
}
|
||||
|
||||
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
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 storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// WeakMap for heavy data that auto-GCs when nodes are removed
|
||||
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
|
||||
|
||||
// Performance tracking
|
||||
const performanceMetrics = reactive<PerformanceMetrics>({
|
||||
fps: 0,
|
||||
frameTime: 0,
|
||||
updateTime: 0,
|
||||
nodeCount: 0,
|
||||
culledCount: 0,
|
||||
callbackUpdateCount: 0,
|
||||
rafUpdateCount: 0,
|
||||
adaptiveQuality: false
|
||||
})
|
||||
|
||||
// Spatial indexing using QuadTree
|
||||
const spatialIndex = new QuadTree<string>(
|
||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||
)
|
||||
let lastSpatialQueryTime = 0
|
||||
|
||||
// Spatial metrics
|
||||
const spatialMetrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
nodesInIndex: 0
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
// Change detection state
|
||||
const lastNodesSnapshot = new Map<
|
||||
string,
|
||||
{ pos: [number, number]; size: [number, number] }
|
||||
>()
|
||||
|
||||
const attachMetadata = (node: LGraphNode) => {
|
||||
nodeMetadata.set(node, {
|
||||
lastRenderTime: performance.now(),
|
||||
cachedBounds: null,
|
||||
lodLevel: 'high',
|
||||
spatialIndex: undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Extract safe widget data
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
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: value,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined, // Already a valid WidgetValue
|
||||
options: undefined,
|
||||
callback: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
})
|
||||
performanceMetrics.callbackUpdateCount++
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// 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
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node - now with reduced nesting
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Uncomment when needed for future features
|
||||
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
|
||||
// let metadata = nodeMetadata.get(node)
|
||||
// if (!metadata) {
|
||||
// attachMetadata(node)
|
||||
// metadata = nodeMetadata.get(node)!
|
||||
// }
|
||||
// return metadata
|
||||
// }
|
||||
|
||||
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(), 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all pending updates
|
||||
criticalUpdates.clear()
|
||||
pendingUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
updateScheduled = false
|
||||
|
||||
// Sync with graph state
|
||||
syncWithGraph()
|
||||
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
spatialIndex.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
if (!nodeState.has(id)) {
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
}
|
||||
})
|
||||
|
||||
// Update performance metrics
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(s) => s.culled
|
||||
).length
|
||||
}
|
||||
|
||||
// Most performant: Direct position sync without re-setting entire node
|
||||
// Query visible nodes using QuadTree spatial index
|
||||
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Use QuadTree for fast spatial query
|
||||
const results: string[] = spatialIndex.query(viewportBounds)
|
||||
const visibleIds = new Set(results)
|
||||
|
||||
lastSpatialQueryTime = performance.now() - startTime
|
||||
spatialMetrics.queryTime = lastSpatialQueryTime
|
||||
|
||||
return visibleIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects position changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
|
||||
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] })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects size changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
|
||||
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] })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates spatial index for a node if bounds changed
|
||||
*/
|
||||
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.update(id, bounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates performance metrics after change detection
|
||||
*/
|
||||
const updatePerformanceMetrics = (
|
||||
startTime: number,
|
||||
positionUpdates: number,
|
||||
sizeUpdates: number
|
||||
): void => {
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(state) => state.culled
|
||||
).length
|
||||
spatialMetrics.nodesInIndex = spatialIndex.size
|
||||
|
||||
if (positionUpdates > 0 || sizeUpdates > 0) {
|
||||
performanceMetrics.rafUpdateCount++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main RAF change detection function - now simplified with extracted helpers
|
||||
*/
|
||||
const detectChangesInRAF = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (!graph?._nodes) return
|
||||
|
||||
let positionUpdates = 0
|
||||
let sizeUpdates = 0
|
||||
|
||||
// Process each node for changes
|
||||
for (const node of graph._nodes) {
|
||||
const id = String(node.id)
|
||||
|
||||
const posChanged = detectPositionChanges(node, id)
|
||||
const sizeChanged = detectSizeChanges(node, id)
|
||||
|
||||
if (posChanged) positionUpdates++
|
||||
if (sizeChanged) sizeUpdates++
|
||||
|
||||
// Update spatial index if geometry changed
|
||||
if (posChanged || sizeChanged) {
|
||||
updateSpatialIndex(node, id)
|
||||
}
|
||||
}
|
||||
|
||||
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract safe data for Vue (now with proper callbacks)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
// Set up reactive tracking state
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
void originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove from spatial index
|
||||
spatialIndex.remove(id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
// Clear pending updates
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
nodeState.clear()
|
||||
nodePositions.clear()
|
||||
nodeSizes.clear()
|
||||
lastNodesSnapshot.clear()
|
||||
pendingUpdates.clear()
|
||||
criticalUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
spatialIndex.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
|
||||
// Set up graph event handlers
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
// Listen for property change events from instrumented nodes
|
||||
graph.onTrigger = (action: string, param: unknown) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as {
|
||||
nodeId: string | number
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
const nodeId = String(event.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
if (event.property === 'title') {
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(event.newValue)
|
||||
})
|
||||
} else if (event.property === 'flags.collapsed') {
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
if (originalOnTrigger) {
|
||||
originalOnTrigger(action, param)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Set up event listeners immediately
|
||||
const cleanup = setupEventListeners()
|
||||
|
||||
// Process any existing nodes after event listeners are set up
|
||||
if (graph._nodes && graph._nodes.length > 0) {
|
||||
graph._nodes.forEach((node: LGraphNode) => {
|
||||
if (graph.onNodeAdded) {
|
||||
graph.onNodeAdded(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
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 }
|
||||
>,
|
||||
getNode,
|
||||
setupEventListeners,
|
||||
cleanup,
|
||||
scheduleUpdate,
|
||||
forceSync: syncWithGraph,
|
||||
detectChangesInRAF,
|
||||
getVisibleNodeIds,
|
||||
performanceMetrics,
|
||||
spatialMetrics: readonly(spatialMetrics),
|
||||
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
|
||||
}
|
||||
}
|
||||
186
src/composables/graph/useLOD.ts
Normal file
186
src/composables/graph/useLOD.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom thresholds to determine
|
||||
* how much detail to render for each node component.
|
||||
*
|
||||
* ## LOD Levels
|
||||
*
|
||||
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
|
||||
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
|
||||
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - Reduces DOM element count by up to 80% at low zoom levels
|
||||
* - Minimizes layout calculations and paint operations
|
||||
* - Enables smooth performance with 1000+ nodes
|
||||
* - Maintains visual fidelity when detail is actually visible
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
|
||||
*
|
||||
* // In template
|
||||
* <NodeWidgets v-if="shouldRenderWidgets" />
|
||||
* <NodeSlots v-if="shouldRenderSlots" />
|
||||
* ```
|
||||
*/
|
||||
import { type Ref, computed, readonly } from 'vue'
|
||||
|
||||
export enum LODLevel {
|
||||
MINIMAL = 'minimal', // zoom <= 0.4
|
||||
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
|
||||
FULL = 'full' // zoom > 0.8
|
||||
}
|
||||
|
||||
export interface LODConfig {
|
||||
renderWidgets: boolean
|
||||
renderSlots: boolean
|
||||
renderContent: boolean
|
||||
renderSlotLabels: boolean
|
||||
renderWidgetLabels: boolean
|
||||
cssClass: string
|
||||
}
|
||||
|
||||
// LOD configuration for each level
|
||||
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
|
||||
[LODLevel.FULL]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: true,
|
||||
renderSlotLabels: true,
|
||||
renderWidgetLabels: true,
|
||||
cssClass: 'lg-node--lod-full'
|
||||
},
|
||||
[LODLevel.REDUCED]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-reduced'
|
||||
},
|
||||
[LODLevel.MINIMAL]: {
|
||||
renderWidgets: false,
|
||||
renderSlots: false,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-minimal'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LOD (Level of Detail) state based on zoom level
|
||||
*
|
||||
* @param zoomRef - Reactive reference to current zoom level (camera.z)
|
||||
* @returns LOD state and configuration
|
||||
*/
|
||||
export function useLOD(zoomRef: Ref<number>) {
|
||||
// Continuous LOD score (0-1) for smooth transitions
|
||||
const lodScore = computed(() => {
|
||||
const zoom = zoomRef.value
|
||||
return Math.max(0, Math.min(1, zoom))
|
||||
})
|
||||
|
||||
// Determine current LOD level based on zoom
|
||||
const lodLevel = computed<LODLevel>(() => {
|
||||
const zoom = zoomRef.value
|
||||
|
||||
if (zoom > 0.8) return LODLevel.FULL
|
||||
if (zoom > 0.4) return LODLevel.REDUCED
|
||||
return LODLevel.MINIMAL
|
||||
})
|
||||
|
||||
// Get configuration for current LOD level
|
||||
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
|
||||
|
||||
// Convenience computed properties for common rendering decisions
|
||||
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
|
||||
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
|
||||
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
|
||||
const shouldRenderSlotLabels = computed(
|
||||
() => lodConfig.value.renderSlotLabels
|
||||
)
|
||||
const shouldRenderWidgetLabels = computed(
|
||||
() => lodConfig.value.renderWidgetLabels
|
||||
)
|
||||
|
||||
// CSS class for styling based on LOD level
|
||||
const lodCssClass = computed(() => lodConfig.value.cssClass)
|
||||
|
||||
// Get essential widgets for reduced LOD (only interactive controls)
|
||||
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
|
||||
if (lodLevel.value === LODLevel.FULL) return widgets
|
||||
if (lodLevel.value === LODLevel.MINIMAL) return []
|
||||
|
||||
// For reduced LOD, filter to essential widget types only
|
||||
return widgets.filter((widget: any) => {
|
||||
const type = widget?.type?.toLowerCase()
|
||||
return [
|
||||
'combo',
|
||||
'select',
|
||||
'toggle',
|
||||
'boolean',
|
||||
'slider',
|
||||
'number'
|
||||
].includes(type)
|
||||
})
|
||||
}
|
||||
|
||||
// Performance metrics for debugging
|
||||
const lodMetrics = computed(() => ({
|
||||
level: lodLevel.value,
|
||||
zoom: zoomRef.value,
|
||||
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
|
||||
slotCount: shouldRenderSlots.value ? 'full' : 'none'
|
||||
}))
|
||||
|
||||
return {
|
||||
// Core LOD state
|
||||
lodLevel: readonly(lodLevel),
|
||||
lodConfig: readonly(lodConfig),
|
||||
lodScore: readonly(lodScore),
|
||||
|
||||
// Rendering decisions
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels,
|
||||
|
||||
// Styling
|
||||
lodCssClass,
|
||||
|
||||
// Utilities
|
||||
getEssentialWidgets,
|
||||
lodMetrics
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LOD level thresholds for configuration or debugging
|
||||
*/
|
||||
export const LOD_THRESHOLDS = {
|
||||
FULL_THRESHOLD: 0.8,
|
||||
REDUCED_THRESHOLD: 0.4,
|
||||
MINIMAL_THRESHOLD: 0.0
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Check if zoom level supports a specific feature
|
||||
*/
|
||||
export function supportsFeatureAtZoom(
|
||||
zoom: number,
|
||||
feature: keyof LODConfig
|
||||
): boolean {
|
||||
const level =
|
||||
zoom > 0.8
|
||||
? LODLevel.FULL
|
||||
: zoom > 0.4
|
||||
? LODLevel.REDUCED
|
||||
: LODLevel.MINIMAL
|
||||
return LOD_CONFIGS[level][feature] as boolean
|
||||
}
|
||||
212
src/composables/graph/useSpatialIndex.ts
Normal file
212
src/composables/graph/useSpatialIndex.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Composable for spatial indexing of nodes using QuadTree
|
||||
* Integrates with useGraphNodeManager for efficient viewport culling
|
||||
*/
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree'
|
||||
|
||||
export interface SpatialIndexOptions {
|
||||
worldBounds?: Bounds
|
||||
maxDepth?: number
|
||||
maxItemsPerNode?: number
|
||||
enableDebugVisualization?: boolean
|
||||
updateDebounceMs?: number
|
||||
}
|
||||
|
||||
interface SpatialMetrics {
|
||||
queryTime: number
|
||||
totalNodes: number
|
||||
visibleNodes: number
|
||||
treeDepth: number
|
||||
rebuildCount: number
|
||||
}
|
||||
|
||||
export const useSpatialIndex = (options: SpatialIndexOptions = {}) => {
|
||||
// Default world bounds (can be expanded dynamically)
|
||||
const defaultBounds: Bounds = {
|
||||
x: -10000,
|
||||
y: -10000,
|
||||
width: 20000,
|
||||
height: 20000
|
||||
}
|
||||
|
||||
// QuadTree instance
|
||||
const quadTree = ref<QuadTree<string> | null>(null)
|
||||
|
||||
// Performance metrics
|
||||
const metrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
totalNodes: 0,
|
||||
visibleNodes: 0,
|
||||
treeDepth: 0,
|
||||
rebuildCount: 0
|
||||
})
|
||||
|
||||
// Debug visualization data (unused for now but may be used in future)
|
||||
// const debugBounds = ref<Bounds[]>([])
|
||||
|
||||
// Initialize QuadTree
|
||||
const initialize = (bounds: Bounds = defaultBounds) => {
|
||||
quadTree.value = new QuadTree<string>(bounds, {
|
||||
maxDepth: options.maxDepth ?? 6,
|
||||
maxItemsPerNode: options.maxItemsPerNode ?? 4
|
||||
})
|
||||
metrics.rebuildCount++
|
||||
}
|
||||
|
||||
// Add or update node in spatial index
|
||||
const updateNode = (
|
||||
nodeId: string,
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => {
|
||||
if (!quadTree.value) {
|
||||
initialize()
|
||||
}
|
||||
|
||||
const bounds: Bounds = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
}
|
||||
|
||||
// Use insert instead of update - insert handles both new and existing nodes
|
||||
quadTree.value!.insert(nodeId, bounds, nodeId)
|
||||
metrics.totalNodes = quadTree.value!.size
|
||||
}
|
||||
|
||||
// Batch update for multiple nodes
|
||||
const batchUpdate = (
|
||||
updates: Array<{
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
}>
|
||||
) => {
|
||||
if (!quadTree.value) {
|
||||
initialize()
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const bounds: Bounds = {
|
||||
x: update.position.x,
|
||||
y: update.position.y,
|
||||
width: update.size.width,
|
||||
height: update.size.height
|
||||
}
|
||||
// Use insert instead of update - insert handles both new and existing nodes
|
||||
quadTree.value!.insert(update.id, bounds, update.id)
|
||||
}
|
||||
|
||||
metrics.totalNodes = quadTree.value!.size
|
||||
}
|
||||
|
||||
// Remove node from spatial index
|
||||
const removeNode = (nodeId: string) => {
|
||||
if (!quadTree.value) return
|
||||
|
||||
quadTree.value.remove(nodeId)
|
||||
metrics.totalNodes = quadTree.value.size
|
||||
}
|
||||
|
||||
// Query nodes within viewport bounds
|
||||
const queryViewport = (viewportBounds: Bounds): string[] => {
|
||||
if (!quadTree.value) return []
|
||||
|
||||
const startTime = performance.now()
|
||||
const nodeIds = quadTree.value.query(viewportBounds)
|
||||
const queryTime = performance.now() - startTime
|
||||
|
||||
metrics.queryTime = queryTime
|
||||
metrics.visibleNodes = nodeIds.length
|
||||
|
||||
return nodeIds
|
||||
}
|
||||
|
||||
// Get nodes within a radius (for proximity queries)
|
||||
const queryRadius = (
|
||||
center: { x: number; y: number },
|
||||
radius: number
|
||||
): string[] => {
|
||||
if (!quadTree.value) return []
|
||||
|
||||
const bounds: Bounds = {
|
||||
x: center.x - radius,
|
||||
y: center.y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
}
|
||||
|
||||
return quadTree.value.query(bounds)
|
||||
}
|
||||
|
||||
// Clear all nodes
|
||||
const clear = () => {
|
||||
if (!quadTree.value) return
|
||||
|
||||
quadTree.value.clear()
|
||||
metrics.totalNodes = 0
|
||||
metrics.visibleNodes = 0
|
||||
}
|
||||
|
||||
// Rebuild tree (useful after major layout changes)
|
||||
const rebuild = (
|
||||
nodes: Map<
|
||||
string,
|
||||
{
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
}
|
||||
>
|
||||
) => {
|
||||
initialize()
|
||||
|
||||
const updates = Array.from(nodes.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
position: data.position,
|
||||
size: data.size
|
||||
}))
|
||||
|
||||
batchUpdate(updates)
|
||||
}
|
||||
|
||||
// Get debug visualization data
|
||||
const getDebugVisualization = () => {
|
||||
if (!quadTree.value || !options.enableDebugVisualization) return null
|
||||
|
||||
return quadTree.value.getDebugInfo()
|
||||
}
|
||||
|
||||
// Debounced update for performance
|
||||
const debouncedUpdateNode = useDebounceFn(
|
||||
updateNode,
|
||||
options.updateDebounceMs ?? 16
|
||||
)
|
||||
|
||||
return {
|
||||
// Core functions
|
||||
initialize,
|
||||
updateNode,
|
||||
batchUpdate,
|
||||
removeNode,
|
||||
queryViewport,
|
||||
queryRadius,
|
||||
clear,
|
||||
rebuild,
|
||||
|
||||
// Debounced version for high-frequency updates
|
||||
debouncedUpdateNode,
|
||||
|
||||
// Metrics
|
||||
metrics: computed(() => metrics),
|
||||
|
||||
// Debug
|
||||
getDebugVisualization,
|
||||
|
||||
// Direct access to QuadTree (for advanced usage)
|
||||
quadTree: computed(() => quadTree.value)
|
||||
}
|
||||
}
|
||||
151
src/composables/graph/useTransformSettling.ts
Normal file
151
src/composables/graph/useTransformSettling.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useDebounceFn, useEventListener, useThrottleFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
export interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 200
|
||||
*/
|
||||
settleDelay?: number
|
||||
/**
|
||||
* Whether to track both zoom (wheel) and pan (pointer drag) interactions
|
||||
* @default false
|
||||
*/
|
||||
trackPan?: boolean
|
||||
/**
|
||||
* Throttle delay for high-frequency pointermove events (only used when trackPan is true)
|
||||
* @default 16 (~60fps)
|
||||
*/
|
||||
pointerMoveThrottle?: number
|
||||
/**
|
||||
* Whether to use passive event listeners (better performance but can't preventDefault)
|
||||
* @default true
|
||||
*/
|
||||
passive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when canvas transforms (zoom/pan) are actively changing vs settled.
|
||||
*
|
||||
* This composable helps optimize rendering quality during transformations.
|
||||
* When the user is actively zooming or panning, we can reduce rendering quality
|
||||
* for better performance. Once the transform "settles" (stops changing), we can
|
||||
* trigger high-quality re-rasterization.
|
||||
*
|
||||
* The settling concept prevents constant quality switching during interactions
|
||||
* by waiting for a period of inactivity before considering the transform complete.
|
||||
*
|
||||
* Uses VueUse's useEventListener for automatic cleanup and useDebounceFn for
|
||||
* efficient settle detection.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { isTransforming } = useTransformSettling(canvasRef, {
|
||||
* settleDelay: 200,
|
||||
* trackPan: true
|
||||
* })
|
||||
*
|
||||
* // Use in CSS classes or rendering logic
|
||||
* const cssClass = computed(() => ({
|
||||
* 'low-quality': isTransforming.value,
|
||||
* 'high-quality': !isTransforming.value
|
||||
* }))
|
||||
* ```
|
||||
*/
|
||||
export function useTransformSettling(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
options: TransformSettlingOptions = {}
|
||||
) {
|
||||
const {
|
||||
settleDelay = 200,
|
||||
trackPan = false,
|
||||
pointerMoveThrottle = 16,
|
||||
passive = true
|
||||
} = options
|
||||
|
||||
const isTransforming = ref(false)
|
||||
let isPanning = false
|
||||
|
||||
/**
|
||||
* Mark transform as active
|
||||
*/
|
||||
const markTransformActive = () => {
|
||||
isTransforming.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transform as settled (debounced)
|
||||
*/
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
/**
|
||||
* Handle any transform event - mark active then queue settle
|
||||
*/
|
||||
const handleTransformEvent = () => {
|
||||
markTransformActive()
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
// Wheel handler
|
||||
const handleWheel = () => {
|
||||
handleTransformEvent()
|
||||
}
|
||||
|
||||
// Pointer handlers for panning
|
||||
const handlePointerDown = () => {
|
||||
if (trackPan) {
|
||||
isPanning = true
|
||||
handleTransformEvent()
|
||||
}
|
||||
}
|
||||
|
||||
// Throttled pointer move handler for performance
|
||||
const handlePointerMove = trackPan
|
||||
? useThrottleFn(() => {
|
||||
if (isPanning) {
|
||||
handleTransformEvent()
|
||||
}
|
||||
}, pointerMoveThrottle)
|
||||
: undefined
|
||||
|
||||
const handlePointerEnd = () => {
|
||||
if (trackPan) {
|
||||
isPanning = false
|
||||
// Don't immediately stop - let the debounced settle handle it
|
||||
}
|
||||
}
|
||||
|
||||
// Register event listeners with auto-cleanup
|
||||
useEventListener(target, 'wheel', handleWheel, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
|
||||
if (trackPan) {
|
||||
useEventListener(target, 'pointerdown', handlePointerDown, {
|
||||
capture: true
|
||||
})
|
||||
|
||||
if (handlePointerMove) {
|
||||
useEventListener(target, 'pointermove', handlePointerMove, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
}
|
||||
|
||||
useEventListener(target, 'pointerup', handlePointerEnd, {
|
||||
capture: true
|
||||
})
|
||||
|
||||
useEventListener(target, 'pointercancel', handlePointerEnd, {
|
||||
capture: true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isTransforming
|
||||
}
|
||||
}
|
||||
117
src/composables/graph/useWidgetRenderer.ts
Normal file
117
src/composables/graph/useWidgetRenderer.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Widget renderer composable for Vue node system
|
||||
* Maps LiteGraph widget types to Vue components
|
||||
*/
|
||||
import {
|
||||
WidgetType,
|
||||
widgetTypeToComponent
|
||||
} from '@/components/graph/vueWidgets/widgetRegistry'
|
||||
|
||||
/**
|
||||
* Static mapping of LiteGraph widget types to Vue widget component names
|
||||
* Moved outside function to prevent recreation on every call
|
||||
*/
|
||||
const TYPE_TO_ENUM_MAP: Record<string, string> = {
|
||||
// Number inputs
|
||||
number: WidgetType.NUMBER,
|
||||
slider: WidgetType.SLIDER,
|
||||
INT: WidgetType.INT,
|
||||
FLOAT: WidgetType.FLOAT,
|
||||
|
||||
// Text inputs
|
||||
text: WidgetType.STRING,
|
||||
string: WidgetType.STRING,
|
||||
STRING: WidgetType.STRING,
|
||||
|
||||
// Selection
|
||||
combo: WidgetType.COMBO,
|
||||
COMBO: WidgetType.COMBO,
|
||||
|
||||
// Boolean
|
||||
toggle: WidgetType.TOGGLESWITCH,
|
||||
boolean: WidgetType.BOOLEAN,
|
||||
BOOLEAN: WidgetType.BOOLEAN,
|
||||
|
||||
// Multiline text
|
||||
multiline: WidgetType.TEXTAREA,
|
||||
textarea: WidgetType.TEXTAREA,
|
||||
customtext: WidgetType.TEXTAREA,
|
||||
MARKDOWN: WidgetType.MARKDOWN,
|
||||
|
||||
// Advanced widgets
|
||||
color: WidgetType.COLOR,
|
||||
COLOR: WidgetType.COLOR,
|
||||
image: WidgetType.IMAGE,
|
||||
IMAGE: WidgetType.IMAGE,
|
||||
imagecompare: WidgetType.IMAGECOMPARE,
|
||||
IMAGECOMPARE: WidgetType.IMAGECOMPARE,
|
||||
galleria: WidgetType.GALLERIA,
|
||||
GALLERIA: WidgetType.GALLERIA,
|
||||
file: WidgetType.FILEUPLOAD,
|
||||
fileupload: WidgetType.FILEUPLOAD,
|
||||
FILEUPLOAD: WidgetType.FILEUPLOAD,
|
||||
|
||||
// Button widget
|
||||
button: WidgetType.BUTTON,
|
||||
BUTTON: WidgetType.BUTTON,
|
||||
|
||||
// Chart widget
|
||||
chart: WidgetType.CHART,
|
||||
CHART: WidgetType.CHART
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Pre-computed widget support map for O(1) lookups
|
||||
* Maps widget type directly to boolean for fast shouldRenderAsVue checks
|
||||
*/
|
||||
const WIDGET_SUPPORT_MAP = new Map(
|
||||
Object.entries(TYPE_TO_ENUM_MAP).map(([type, enumValue]) => [
|
||||
type,
|
||||
widgetTypeToComponent[enumValue] !== undefined
|
||||
])
|
||||
)
|
||||
|
||||
export const ESSENTIAL_WIDGET_TYPES = new Set([
|
||||
'combo',
|
||||
'COMBO',
|
||||
'select',
|
||||
'toggle',
|
||||
'boolean',
|
||||
'BOOLEAN',
|
||||
'slider',
|
||||
'number',
|
||||
'INT',
|
||||
'FLOAT'
|
||||
])
|
||||
|
||||
export const useWidgetRenderer = () => {
|
||||
const getWidgetComponent = (widgetType: string): string => {
|
||||
const enumKey = TYPE_TO_ENUM_MAP[widgetType]
|
||||
|
||||
if (enumKey && widgetTypeToComponent[enumKey]) {
|
||||
return enumKey
|
||||
}
|
||||
|
||||
return WidgetType.STRING
|
||||
}
|
||||
|
||||
const shouldRenderAsVue = (widget: {
|
||||
type?: string
|
||||
options?: Record<string, unknown>
|
||||
}): boolean => {
|
||||
if (widget.options?.canvasOnly) return false
|
||||
if (!widget.type) return false
|
||||
|
||||
// Check if widget type is explicitly supported
|
||||
const isSupported = WIDGET_SUPPORT_MAP.get(widget.type)
|
||||
if (isSupported !== undefined) return isSupported
|
||||
|
||||
// Fallback: unknown types are rendered as STRING widget
|
||||
return widgetTypeToComponent[WidgetType.STRING] !== undefined
|
||||
}
|
||||
|
||||
return {
|
||||
getWidgetComponent,
|
||||
shouldRenderAsVue
|
||||
}
|
||||
}
|
||||
155
src/composables/graph/useWidgetValue.ts
Normal file
155
src/composables/graph/useWidgetValue.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Composable for managing widget value synchronization between Vue and LiteGraph
|
||||
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
|
||||
*/
|
||||
import { type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
export interface UseWidgetValueOptions<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
U = T
|
||||
> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component */
|
||||
modelValue: T
|
||||
/** Default value if modelValue is null/undefined */
|
||||
defaultValue: T
|
||||
/** Emit function from component setup */
|
||||
emit: (event: 'update:modelValue', value: T) => void
|
||||
/** Optional value transformer before sending to LiteGraph */
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
export interface UseWidgetValueReturn<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
U = T
|
||||
> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
onChange: (newValue: U) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages widget value synchronization with LiteGraph
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* const { localValue, onChange } = useWidgetValue({
|
||||
* widget: props.widget,
|
||||
* modelValue: props.modelValue,
|
||||
* defaultValue: ''
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Local value for immediate UI updates
|
||||
const localValue = ref<T>(modelValue ?? defaultValue)
|
||||
|
||||
// Handle user changes
|
||||
const onChange = (newValue: U) => {
|
||||
// Handle different PrimeVue component signatures
|
||||
let processedValue: T
|
||||
if (transform) {
|
||||
processedValue = transform(newValue)
|
||||
} else {
|
||||
// Ensure type safety - only cast when types are compatible
|
||||
if (
|
||||
typeof newValue === typeof defaultValue ||
|
||||
newValue === null ||
|
||||
newValue === undefined
|
||||
) {
|
||||
processedValue = (newValue ?? defaultValue) as T
|
||||
} else {
|
||||
console.warn(
|
||||
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
|
||||
)
|
||||
processedValue = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Update local state for immediate UI feedback
|
||||
localValue.value = processedValue
|
||||
|
||||
// 2. Emit to parent component
|
||||
emit('update:modelValue', processedValue)
|
||||
}
|
||||
|
||||
// Watch for external updates from LiteGraph
|
||||
watch(
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue ?? defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
localValue: localValue as Ref<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
emit: (event: 'update:modelValue', value: string) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: '',
|
||||
emit,
|
||||
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for number widgets
|
||||
*/
|
||||
export function useNumberWidgetValue(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
emit: (event: 'update:modelValue', value: number) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: 0,
|
||||
emit,
|
||||
transform: (value: number | number[]) => {
|
||||
// Handle PrimeVue Slider which can emit number | number[]
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value[0] ?? 0 : 0
|
||||
}
|
||||
return Number(value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for boolean widgets
|
||||
*/
|
||||
export function useBooleanWidgetValue(
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
modelValue: boolean,
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
82
src/composables/useFeatureFlags.ts
Normal file
82
src/composables/useFeatureFlags.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Feature flags composable for Vue node system
|
||||
* Provides safe toggles for experimental features
|
||||
*/
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
/**
|
||||
* Enable Vue-based node rendering
|
||||
* When disabled, falls back to standard LiteGraph canvas rendering
|
||||
*/
|
||||
const isVueNodesEnabled = computed(() => {
|
||||
try {
|
||||
return settingStore.get('Comfy.VueNodes.Enabled' as any) ?? true // Default to true for development
|
||||
} catch {
|
||||
return true // Default to true for development
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Enable Vue widget rendering within Vue nodes
|
||||
* When disabled, Vue nodes render without widgets (structure only)
|
||||
*/
|
||||
const isVueWidgetsEnabled = computed(() => {
|
||||
try {
|
||||
return settingStore.get('Comfy.VueNodes.Widgets' as any) ?? true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Development mode features (debug panel, etc.)
|
||||
* Automatically enabled in development builds
|
||||
*/
|
||||
const isDevModeEnabled = computed(() => {
|
||||
try {
|
||||
return (
|
||||
settingStore.get('Comfy.DevMode' as any) ??
|
||||
process.env.NODE_ENV === 'development'
|
||||
)
|
||||
} catch {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if Vue nodes should be rendered at all
|
||||
* Combines multiple conditions for safety
|
||||
*/
|
||||
const shouldRenderVueNodes = computed(
|
||||
() =>
|
||||
isVueNodesEnabled.value &&
|
||||
// Add any other safety conditions here
|
||||
true
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync the Vue nodes feature flag with LiteGraph global settings
|
||||
*/
|
||||
const syncVueNodesFlag = () => {
|
||||
LiteGraph.vueNodesMode = isVueNodesEnabled.value
|
||||
console.log('Vue nodes mode:', LiteGraph.vueNodesMode)
|
||||
}
|
||||
|
||||
// Watch for changes and update LiteGraph
|
||||
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
|
||||
|
||||
return {
|
||||
isVueNodesEnabled,
|
||||
isVueWidgetsEnabled,
|
||||
isDevModeEnabled,
|
||||
shouldRenderVueNodes,
|
||||
syncVueNodesFlag
|
||||
}
|
||||
}
|
||||
29
src/composables/widgets/useChartWidget.ts
Normal file
29
src/composables/widgets/useChartWidget.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
type ChartInputSpec,
|
||||
type InputSpec as InputSpecV2,
|
||||
isChartInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IChartWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useChartWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IChartWidget => {
|
||||
if (!isChartInputSpec(inputSpec)) {
|
||||
throw new Error('Invalid input spec for chart widget')
|
||||
}
|
||||
|
||||
const { name, options = {} } = inputSpec as ChartInputSpec
|
||||
|
||||
const chartType = options.type || 'line'
|
||||
|
||||
const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
|
||||
serialize: true,
|
||||
type: chartType,
|
||||
...options
|
||||
}) as IChartWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
21
src/composables/widgets/useColorWidget.ts
Normal file
21
src/composables/widgets/useColorWidget.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
ColorInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IColorWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
}) as IColorWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
21
src/composables/widgets/useFileUploadWidget.ts
Normal file
21
src/composables/widgets/useFileUploadWidget.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
FileUploadInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IFileUploadWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
|
||||
const { name, options = {} } = inputSpec as FileUploadInputSpec
|
||||
|
||||
const widget = node.addWidget('fileupload', name, '', () => {}, {
|
||||
serialize: true,
|
||||
...(options as Record<string, unknown>)
|
||||
}) as IFileUploadWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
27
src/composables/widgets/useGalleriaWidget.ts
Normal file
27
src/composables/widgets/useGalleriaWidget.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
GalleriaInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IGalleriaWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
|
||||
const { name, options = {} } = inputSpec as GalleriaInputSpec
|
||||
|
||||
const widget = node.addWidget(
|
||||
'galleria',
|
||||
name,
|
||||
options.images || [],
|
||||
() => {},
|
||||
{
|
||||
serialize: true,
|
||||
...options
|
||||
}
|
||||
) as IGalleriaWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
21
src/composables/widgets/useImageCompareWidget.ts
Normal file
21
src/composables/widgets/useImageCompareWidget.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
ImageCompareInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IImageCompareWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useImageCompareWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IImageCompareWidget => {
|
||||
const { name, options = {} } = inputSpec as ImageCompareInputSpec
|
||||
|
||||
const widget = node.addWidget('imagecompare', name, ['', ''], () => {}, {
|
||||
serialize: true,
|
||||
...options
|
||||
}) as IImageCompareWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
21
src/composables/widgets/useImageWidget.ts
Normal file
21
src/composables/widgets/useImageWidget.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
ImageInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IImageWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useImageWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IImageWidget => {
|
||||
const { name, options = {} } = inputSpec as ImageInputSpec
|
||||
|
||||
const widget = node.addWidget('image', name, '', () => {}, {
|
||||
serialize: true,
|
||||
...options
|
||||
}) as IImageWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
22
src/composables/widgets/useMultiSelectWidget.ts
Normal file
22
src/composables/widgets/useMultiSelectWidget.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
MultiSelectInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { IMultiSelectWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
|
||||
const { name, options = {} } = inputSpec as MultiSelectInputSpec
|
||||
|
||||
const widget = node.addWidget('multiselect', name, [], () => {}, {
|
||||
serialize: true,
|
||||
values: options.values || [],
|
||||
...options
|
||||
}) as IMultiSelectWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
29
src/composables/widgets/useSelectButtonWidget.ts
Normal file
29
src/composables/widgets/useSelectButtonWidget.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
SelectButtonInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { ISelectButtonWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
|
||||
const { name, options = {} } = inputSpec as SelectButtonInputSpec
|
||||
const values = options.values || []
|
||||
|
||||
const widget = node.addWidget(
|
||||
'selectbutton',
|
||||
name,
|
||||
values[0] || '',
|
||||
(_value: string) => {},
|
||||
{
|
||||
serialize: true,
|
||||
values,
|
||||
...options
|
||||
}
|
||||
) as ISelectButtonWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
29
src/composables/widgets/useTextareaWidget.ts
Normal file
29
src/composables/widgets/useTextareaWidget.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
TextareaInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { ITextareaWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
|
||||
const { name, options = {} } = inputSpec as TextareaInputSpec
|
||||
|
||||
const widget = node.addWidget(
|
||||
'textarea',
|
||||
name,
|
||||
options.default || '',
|
||||
() => {},
|
||||
{
|
||||
serialize: true,
|
||||
rows: options.rows || 5,
|
||||
cols: options.cols || 50,
|
||||
...options
|
||||
}
|
||||
) as ITextareaWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
25
src/composables/widgets/useTreeSelectWidget.ts
Normal file
25
src/composables/widgets/useTreeSelectWidget.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
TreeSelectInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
import type { ITreeSelectWidget } from '../../lib/litegraph/src/types/widgets'
|
||||
|
||||
export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
|
||||
const { name, options = {} } = inputSpec as TreeSelectInputSpec
|
||||
const isMultiple = options.multiple || false
|
||||
const defaultValue = isMultiple ? [] : ''
|
||||
|
||||
const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
|
||||
serialize: true,
|
||||
values: options.values || [],
|
||||
multiple: isMultiple,
|
||||
...options
|
||||
}) as ITreeSelectWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user