diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 1e215c60a..96841ebc0 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -33,8 +33,8 @@ @@ -96,11 +96,9 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu import SideToolbar from '@/components/sidebar/SideToolbar.vue' import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue' import { useChainCallback } from '@/composables/functional/useChainCallback' -import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' -import type { - NodeState, - VueNodeData -} from '@/composables/graph/useGraphNodeManager' +import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers' +import { useViewportCulling } from '@/composables/graph/useViewportCulling' +import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useNodeBadge } from '@/composables/node/useNodeBadge' import { useCanvasDrop } from '@/composables/useCanvasDrop' import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation' @@ -113,15 +111,8 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave' import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence' import { CORE_SETTINGS } from '@/constants/coreSettings' import { i18n, t } from '@/i18n' -import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import TransformPane from '@/renderer/core/layout/TransformPane.vue' -import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' -import { layoutStore } from '@/renderer/core/layout/store/layoutStore' -import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync' -import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync' -import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync' -import { LayoutSource } from '@/renderer/core/layout/types' -import { useTransformState } from '@/renderer/core/layout/useTransformState' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import { UnauthorizedError, api } from '@/scripts/api' @@ -155,7 +146,6 @@ const workspaceStore = useWorkspaceStore() const canvasStore = useCanvasStore() const executionStore = useExecutionStore() const toastStore = useToastStore() -const layoutMutations = useLayoutMutations() const betaMenuEnabled = computed( () => settingStore.get('Comfy.UseNewMenu') !== 'Disabled' ) @@ -172,300 +162,32 @@ const selectionToolboxEnabled = computed(() => const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible')) -// Feature flags (Vue-related) +// Feature flags const { shouldRenderVueNodes } = useVueFeatureFlags() - const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value) -// Vue node lifecycle management - initialize after graph is ready -let nodeManager: ReturnType | null = null -let cleanupNodeManager: (() => void) | null = null - -// Slot layout sync management -let slotSync: ReturnType | null = null -let slotSyncStarted = false -let linkSync: ReturnType | null = null -const vueNodeData = ref>(new Map()) -const nodeState = ref>(new Map()) -const nodePositions = ref>( - new Map() +// Vue node system +const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled) +const viewportCulling = useViewportCulling( + isVueNodesEnabled, + vueNodeLifecycle.vueNodeData, + vueNodeLifecycle.nodeDataTrigger, + vueNodeLifecycle.nodeManager ) -const nodeSizes = ref>( - new Map() -) -let detectChangesInRAF = () => {} +const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager) -// Initialize node manager when graph becomes available -// Add a reactivity trigger to force computed re-evaluation -const nodeDataTrigger = ref(0) - -const initializeNodeManager = () => { - if (!comfyApp.graph || nodeManager) return - nodeManager = useGraphNodeManager(comfyApp.graph) - cleanupNodeManager = nodeManager.cleanup - // Use the manager's reactive maps directly - vueNodeData.value = nodeManager.vueNodeData - nodeState.value = nodeManager.nodeState - nodePositions.value = nodeManager.nodePositions - nodeSizes.value = nodeManager.nodeSizes - detectChangesInRAF = nodeManager.detectChangesInRAF - - // Initialize layout system with existing nodes - const nodes = comfyApp.graph._nodes.map((node: any) => ({ - id: node.id.toString(), - pos: node.pos, - size: node.size - })) - layoutStore.initializeFromLiteGraph(nodes) - - // Seed reroutes into the Layout Store so hit-testing uses the new path - for (const reroute of comfyApp.graph.reroutes.values()) { - const [x, y] = reroute.pos - const parent = reroute.parentId ?? undefined - const linkIds = Array.from(reroute.linkIds) - layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds) - } - - // Seed existing links into the Layout Store (topology only) - for (const link of comfyApp.graph._links.values()) { - layoutMutations.createLink( - link.id, - link.origin_id, - link.origin_slot, - link.target_id, - link.target_slot - ) - } - - // Initialize layout sync (one-way: Layout Store → LiteGraph) - const { startSync } = useLayoutSync() - startSync(canvasStore.canvas) - - // Initialize link layout sync for event-driven updates - linkSync = useLinkLayoutSync() - if (canvasStore.canvas) { - linkSync.start(canvasStore.canvas as LGraphCanvas) - } - - // Force computed properties to re-evaluate - nodeDataTrigger.value++ -} - -const disposeNodeManagerAndSyncs = () => { - if (!nodeManager) return - try { - cleanupNodeManager?.() - } catch { - /* empty */ - } - nodeManager = null - cleanupNodeManager = null - - // Clean up link layout sync - if (linkSync) { - linkSync.stop() - linkSync = null - } - - // Reset reactive maps to inert defaults - vueNodeData.value = new Map() - nodeState.value = new Map() - nodePositions.value = new Map() - nodeSizes.value = new Map() -} - -// Watch for transformPaneEnabled to gate the node manager lifecycle -watch( - () => isVueNodesEnabled.value && Boolean(comfyApp.graph), - (enabled) => { - if (enabled) { - initializeNodeManager() - } else { - disposeNodeManagerAndSyncs() - } - }, - { immediate: true } -) - -// Consolidated watch for slot layout sync management -watch( - [() => canvasStore.canvas, () => isVueNodesEnabled.value], - ([canvas, vueMode], [, oldVueMode]) => { - const modeChanged = vueMode !== oldVueMode - - // Clear stale slot layouts when switching modes - if (modeChanged) { - layoutStore.clearAllSlotLayouts() - } - - // Switching to Vue - if (vueMode && slotSyncStarted) { - slotSync?.stop() - slotSyncStarted = false - } - - // Switching to LG - const shouldRun = Boolean(canvas?.graph) && !vueMode - if (shouldRun && !slotSyncStarted && canvas) { - // Initialize slot sync if not already created - if (!slotSync) { - slotSync = useSlotLayoutSync() - } - const started = slotSync.attemptStart(canvas as LGraphCanvas) - slotSyncStarted = started - } - }, - { immediate: true } -) - -// Transform state for viewport culling -const { syncWithCanvas } = useTransformState() - -const nodesToRender = computed(() => { - // Early return for zero overhead when Vue nodes are disabled - if (!isVueNodesEnabled.value) { - return [] - } - - // Access trigger to force re-evaluation after nodeManager initialization - void nodeDataTrigger.value - - if (!comfyApp.graph) { - return [] - } - - const allNodes = Array.from(vueNodeData.value.values()) - - // Apply viewport culling - check if node bounds intersect with viewport - if (nodeManager && canvasStore.canvas && comfyApp.canvas) { - const canvas = canvasStore.canvas - const manager = nodeManager - - // Ensure transform is synced before checking visibility - syncWithCanvas(comfyApp.canvas) - - const ds = canvas.ds - - // Work in screen space - viewport is simply the canvas element size - const viewport_width = canvas.canvas.width - const viewport_height = canvas.canvas.height - - // Add margin that represents a constant distance in canvas space - // Convert canvas units to screen pixels by multiplying by scale - const canvasMarginDistance = 200 // Fixed margin in canvas units - const margin_x = canvasMarginDistance * ds.scale - const margin_y = canvasMarginDistance * ds.scale - - const filtered = allNodes.filter((nodeData) => { - const node = manager.getNode(nodeData.id) - if (!node) return false - - // Transform node position to screen space (same as DOM widgets) - const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale - const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale - const screen_width = node.size[0] * ds.scale - const screen_height = node.size[1] * ds.scale - - // Check if node bounds intersect with expanded viewport (in screen space) - const isVisible = !( - screen_x + screen_width < -margin_x || - screen_x > viewport_width + margin_x || - screen_y + screen_height < -margin_y || - screen_y > viewport_height + margin_y - ) - - return isVisible - }) - - return filtered - } - - return allNodes -}) - -let lastScale = 1 -let lastOffsetX = 0 -let lastOffsetY = 0 +const nodePositions = vueNodeLifecycle.nodePositions +const nodeSizes = vueNodeLifecycle.nodeSizes +const nodesToRender = viewportCulling.nodesToRender const handleTransformUpdate = () => { - // Skip all work if Vue nodes are disabled - if (!isVueNodesEnabled.value) { - return - } - - // Sync transform state only when it changes (avoids reflows) - if (comfyApp.canvas?.ds) { - const currentScale = comfyApp.canvas.ds.scale - const currentOffsetX = comfyApp.canvas.ds.offset[0] - const currentOffsetY = comfyApp.canvas.ds.offset[1] - - if ( - currentScale !== lastScale || - currentOffsetX !== lastOffsetX || - currentOffsetY !== lastOffsetY - ) { - syncWithCanvas(comfyApp.canvas) - lastScale = currentScale - lastOffsetX = currentOffsetX - lastOffsetY = currentOffsetY - } - } - - // Detect node changes during transform updates - detectChangesInRAF() - - // Trigger reactivity for nodesToRender - void nodesToRender.value.length -} - -// Node event handlers -const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => { - if (!canvasStore.canvas || !nodeManager) return - - const node = nodeManager.getNode(nodeData.id) - if (!node) return - - if (!event.ctrlKey && !event.metaKey) { - canvasStore.canvas.deselectAllNodes() - } - - canvasStore.canvas.selectNode(node) - - // Bring node to front when clicked (similar to LiteGraph behavior) - // Skip if node is pinned - if (!node.flags?.pinned) { - layoutMutations.setSource(LayoutSource.Vue) - layoutMutations.bringNodeToFront(nodeData.id) - } - node.selected = true - - canvasStore.updateSelectedItems() -} - -// Handle node collapse state changes -const handleNodeCollapse = (nodeId: string, collapsed: boolean) => { - if (!nodeManager) return - - const node = nodeManager.getNode(nodeId) - if (!node) return - - // Use LiteGraph's collapse method if the state needs to change - const currentCollapsed = node.flags?.collapsed ?? false - if (currentCollapsed !== collapsed) { - node.collapse() - } -} - -// Handle node title updates -const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => { - if (!nodeManager) return - - const node = nodeManager.getNode(nodeId) - if (!node) return - - // Update the node title in LiteGraph for persistence - node.title = newTitle + viewportCulling.handleTransformUpdate( + vueNodeLifecycle.detectChangesInRAF.value + ) } +const handleNodeSelect = nodeEventHandlers.handleNodeSelect +const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse +const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate watchEffect(() => { nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') @@ -673,29 +395,7 @@ onMounted(async () => { comfyAppReady.value = true - // Set up Vue node initialization only when enabled - if (isVueNodesEnabled.value) { - // Set up a one-time listener for when the first node is added - // This handles the case where Vue nodes are enabled but the graph starts empty - // TODO: Replace this with a reactive graph mutations observer when available - if (comfyApp.graph && !nodeManager && comfyApp.graph._nodes.length === 0) { - const originalOnNodeAdded = comfyApp.graph.onNodeAdded - comfyApp.graph.onNodeAdded = function (node: any) { - // Restore original handler - comfyApp.graph.onNodeAdded = originalOnNodeAdded - - // Initialize node manager if needed - if (isVueNodesEnabled.value && !nodeManager) { - initializeNodeManager() - } - - // Call original handler - if (originalOnNodeAdded) { - originalOnNodeAdded.call(this, node) - } - } - } - } + vueNodeLifecycle.setupEmptyGraphListener() comfyApp.canvas.onSelectionChange = useChainCallback( comfyApp.canvas.onSelectionChange, @@ -739,18 +439,6 @@ onMounted(async () => { }) onUnmounted(() => { - if (nodeManager) { - nodeManager.cleanup() - nodeManager = null - } - if (slotSyncStarted) { - slotSync?.stop() - slotSyncStarted = false - } - slotSync = null - if (linkSync) { - linkSync.stop() - linkSync = null - } + vueNodeLifecycle.cleanup() }) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 88b32e919..bb9301488 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -2,7 +2,7 @@ * Vue node lifecycle management for LiteGraph integration * Provides event-driven reactivity with performance optimizations */ -import { nextTick, reactive, readonly } from 'vue' +import { nextTick, reactive } from 'vue' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { LayoutSource } from '@/renderer/core/layout/types' @@ -604,7 +604,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { // Set up widget callbacks BEFORE extracting data (critical order) setupNodeWidgetCallbacks(node) - // Extract safe data for Vue (now with proper callbacks) + // Extract safe data for Vue vueNodeData.set(id, extractVueNodeData(node)) // Set up reactive tracking state @@ -789,16 +789,10 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { } return { - vueNodeData: readonly(vueNodeData) as ReadonlyMap, - nodeState: readonly(nodeState) as ReadonlyMap, - nodePositions: readonly(nodePositions) as ReadonlyMap< - string, - { x: number; y: number } - >, - nodeSizes: readonly(nodeSizes) as ReadonlyMap< - string, - { width: number; height: number } - >, + vueNodeData, + nodeState, + nodePositions, + nodeSizes, getNode, setupEventListeners, cleanup, @@ -807,7 +801,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { detectChangesInRAF, getVisibleNodeIds, performanceMetrics, - spatialMetrics: readonly(spatialMetrics), + spatialMetrics, getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo() } } diff --git a/src/composables/graph/useNodeEventHandlers.ts b/src/composables/graph/useNodeEventHandlers.ts new file mode 100644 index 000000000..377965fe2 --- /dev/null +++ b/src/composables/graph/useNodeEventHandlers.ts @@ -0,0 +1,212 @@ +/** + * Node Event Handlers Composable + * + * Handles all Vue node interaction events including: + * - Node selection with multi-select support + * - Node collapse/expand state management + * - Node title editing and updates + * - Layout mutations for visual feedback + * - Integration with LiteGraph canvas selection system + */ +import type { Ref } from 'vue' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' +import { LayoutSource } from '@/renderer/core/layout/types' +import { useCanvasStore } from '@/stores/graphStore' + +interface NodeManager { + getNode: (id: string) => any +} + +export function useNodeEventHandlers(nodeManager: Ref) { + const canvasStore = useCanvasStore() + const layoutMutations = useLayoutMutations() + + /** + * Handle node selection events + * Supports single selection and multi-select with Ctrl/Cmd + */ + const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => { + if (!canvasStore.canvas || !nodeManager.value) return + + const node = nodeManager.value.getNode(nodeData.id) + if (!node) return + + // Handle multi-select with Ctrl/Cmd key + if (!event.ctrlKey && !event.metaKey) { + canvasStore.canvas.deselectAllNodes() + } + + canvasStore.canvas.selectNode(node) + + // Bring node to front when clicked (similar to LiteGraph behavior) + // Skip if node is pinned to avoid unwanted movement + if (!node.flags?.pinned) { + layoutMutations.setSource(LayoutSource.Vue) + layoutMutations.bringNodeToFront(nodeData.id) + } + + // Ensure node selection state is set + node.selected = true + + // Update canvas selection tracking + canvasStore.updateSelectedItems() + } + + /** + * Handle node collapse/expand state changes + * Uses LiteGraph's native collapse method for proper state management + */ + const handleNodeCollapse = (nodeId: string, collapsed: boolean) => { + if (!nodeManager.value) return + + const node = nodeManager.value.getNode(nodeId) + if (!node) return + + // Use LiteGraph's collapse method if the state needs to change + const currentCollapsed = node.flags?.collapsed ?? false + if (currentCollapsed !== collapsed) { + node.collapse() + } + } + + /** + * Handle node title updates + * Updates the title in LiteGraph for persistence across sessions + */ + const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => { + if (!nodeManager.value) return + + const node = nodeManager.value.getNode(nodeId) + if (!node) return + + // Update the node title in LiteGraph for persistence + node.title = newTitle + } + + /** + * Handle node double-click events + * Can be used for custom actions like opening node editor + */ + const handleNodeDoubleClick = ( + event: PointerEvent, + nodeData: VueNodeData + ) => { + if (!canvasStore.canvas || !nodeManager.value) return + + const node = nodeManager.value.getNode(nodeData.id) + if (!node) return + + // Prevent default browser behavior + event.preventDefault() + + // TODO: add custom double-click behavior here + // For now, ensure node is selected + if (!node.selected) { + handleNodeSelect(event, nodeData) + } + } + + /** + * Handle node right-click context menu events + * Integrates with LiteGraph's context menu system + */ + const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => { + if (!canvasStore.canvas || !nodeManager.value) return + + const node = nodeManager.value.getNode(nodeData.id) + if (!node) return + + // Prevent default context menu + event.preventDefault() + + // Select the node if not already selected + if (!node.selected) { + handleNodeSelect(event, nodeData) + } + + // Let LiteGraph handle the context menu + // The canvas will handle showing the appropriate context menu + } + + /** + * Handle node drag start events + * Prepares node for dragging and sets appropriate visual state + */ + const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => { + if (!canvasStore.canvas || !nodeManager.value) return + + const node = nodeManager.value.getNode(nodeData.id) + if (!node) return + + // Ensure node is selected before dragging + if (!node.selected) { + // Create a synthetic pointer event for selection + const syntheticEvent = new PointerEvent('pointerdown', { + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + bubbles: true + }) + handleNodeSelect(syntheticEvent, nodeData) + } + + // Set drag data for potential drop operations + if (event.dataTransfer) { + event.dataTransfer.setData('application/comfy-node-id', nodeData.id) + event.dataTransfer.effectAllowed = 'move' + } + } + + /** + * Batch select multiple nodes + * Useful for selection toolbox or area selection + */ + const selectNodes = (nodeIds: string[], addToSelection = false) => { + if (!canvasStore.canvas || !nodeManager.value) return + + if (!addToSelection) { + canvasStore.canvas.deselectAllNodes() + } + + nodeIds.forEach((nodeId) => { + const node = nodeManager.value?.getNode(nodeId) + if (node && canvasStore.canvas) { + canvasStore.canvas.selectNode(node) + node.selected = true + } + }) + + canvasStore.updateSelectedItems() + } + + /** + * Deselect specific nodes + */ + const deselectNodes = (nodeIds: string[]) => { + if (!canvasStore.canvas || !nodeManager.value) return + + nodeIds.forEach((nodeId) => { + const node = nodeManager.value?.getNode(nodeId) + if (node) { + node.selected = false + } + }) + + canvasStore.updateSelectedItems() + } + + return { + // Core event handlers + handleNodeSelect, + handleNodeCollapse, + handleNodeTitleUpdate, + handleNodeDoubleClick, + handleNodeRightClick, + handleNodeDragStart, + + // Batch operations + selectNodes, + deselectNodes + } +} diff --git a/src/composables/graph/useViewportCulling.ts b/src/composables/graph/useViewportCulling.ts new file mode 100644 index 000000000..b5c996f93 --- /dev/null +++ b/src/composables/graph/useViewportCulling.ts @@ -0,0 +1,212 @@ +/** + * Viewport Culling Composable + * + * Handles viewport culling optimization for Vue nodes including: + * - Transform state synchronization + * - Visible node calculation with screen space transforms + * - Adaptive margin computation based on zoom level + * - Performance optimizations for large graphs + */ +import { type Ref, computed, readonly, ref } from 'vue' + +import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' +import { useTransformState } from '@/renderer/core/layout/useTransformState' +import { app as comfyApp } from '@/scripts/app' +import { useCanvasStore } from '@/stores/graphStore' + +interface NodeManager { + getNode: (id: string) => any +} + +export function useViewportCulling( + isVueNodesEnabled: Ref, + vueNodeData: Ref>, + nodeDataTrigger: Ref, + nodeManager: Ref +) { + const canvasStore = useCanvasStore() + const { syncWithCanvas } = useTransformState() + + // Transform tracking for performance optimization + const lastScale = ref(1) + const lastOffsetX = ref(0) + const lastOffsetY = ref(0) + + // Current transform state + const currentTransformState = computed(() => ({ + scale: lastScale.value, + offsetX: lastOffsetX.value, + offsetY: lastOffsetY.value + })) + + /** + * Computed property that returns nodes visible in the current viewport + * Implements sophisticated culling algorithm with adaptive margins + */ + const nodesToRender = computed(() => { + if (!isVueNodesEnabled.value) { + return [] + } + + // Access trigger to force re-evaluation after nodeManager initialization + void nodeDataTrigger.value + + if (!comfyApp.graph) { + return [] + } + + const allNodes = Array.from(vueNodeData.value.values()) + + // Apply viewport culling - check if node bounds intersect with viewport + // TODO: use quadtree + if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) { + const canvas = canvasStore.canvas + const manager = nodeManager.value + + // Ensure transform is synced before checking visibility + syncWithCanvas(comfyApp.canvas) + + const ds = canvas.ds + + // Work in screen space - viewport is simply the canvas element size + const viewport_width = canvas.canvas.width + const viewport_height = canvas.canvas.height + + // Add margin that represents a constant distance in canvas space + // Convert canvas units to screen pixels by multiplying by scale + const canvasMarginDistance = 200 // Fixed margin in canvas units + const margin_x = canvasMarginDistance * ds.scale + const margin_y = canvasMarginDistance * ds.scale + + const filtered = allNodes.filter((nodeData) => { + const node = manager.getNode(nodeData.id) + if (!node) return false + + // Transform node position to screen space (same as DOM widgets) + const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale + const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale + const screen_width = node.size[0] * ds.scale + const screen_height = node.size[1] * ds.scale + + // Check if node bounds intersect with expanded viewport (in screen space) + const isVisible = !( + screen_x + screen_width < -margin_x || + screen_x > viewport_width + margin_x || + screen_y + screen_height < -margin_y || + screen_y > viewport_height + margin_y + ) + + return isVisible + }) + + return filtered + } + + return allNodes + }) + + /** + * Handle transform updates with performance optimization + * Only syncs when transform actually changes to avoid unnecessary reflows + */ + const handleTransformUpdate = (detectChangesInRAF: () => void) => { + // Skip all work if Vue nodes are disabled + if (!isVueNodesEnabled.value) { + return + } + + // Sync transform state only when it changes (avoids reflows) + if (comfyApp.canvas?.ds) { + const currentScale = comfyApp.canvas.ds.scale + const currentOffsetX = comfyApp.canvas.ds.offset[0] + const currentOffsetY = comfyApp.canvas.ds.offset[1] + + if ( + currentScale !== lastScale.value || + currentOffsetX !== lastOffsetX.value || + currentOffsetY !== lastOffsetY.value + ) { + syncWithCanvas(comfyApp.canvas) + lastScale.value = currentScale + lastOffsetX.value = currentOffsetX + lastOffsetY.value = currentOffsetY + } + } + + // Detect node changes during transform updates + detectChangesInRAF() + + // Trigger reactivity for nodesToRender + void nodesToRender.value.length + } + + /** + * Calculate if a specific node is visible in viewport + * Useful for individual node visibility checks + */ + const isNodeVisible = (nodeData: VueNodeData): boolean => { + if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) { + return true // Default to visible if culling not available + } + + const canvas = canvasStore.canvas + const node = nodeManager.value.getNode(nodeData.id) + if (!node) return false + + syncWithCanvas(comfyApp.canvas) + const ds = canvas.ds + + const viewport_width = canvas.canvas.width + const viewport_height = canvas.canvas.height + const canvasMarginDistance = 200 + const margin_x = canvasMarginDistance * ds.scale + const margin_y = canvasMarginDistance * ds.scale + + const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale + const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale + const screen_width = node.size[0] * ds.scale + const screen_height = node.size[1] * ds.scale + + return !( + screen_x + screen_width < -margin_x || + screen_x > viewport_width + margin_x || + screen_y + screen_height < -margin_y || + screen_y > viewport_height + margin_y + ) + } + + /** + * Get viewport bounds information for debugging + */ + const getViewportInfo = () => { + if (!canvasStore.canvas || !comfyApp.canvas) { + return null + } + + const canvas = canvasStore.canvas + const ds = canvas.ds + + return { + viewport_width: canvas.canvas.width, + viewport_height: canvas.canvas.height, + scale: ds.scale, + offset: [ds.offset[0], ds.offset[1]], + margin_distance: 200, + margin_x: 200 * ds.scale, + margin_y: 200 * ds.scale + } + } + + return { + nodesToRender, + handleTransformUpdate, + isNodeVisible, + getViewportInfo, + + // Transform state + currentTransformState: readonly(currentTransformState), + lastScale: readonly(lastScale), + lastOffsetX: readonly(lastOffsetX), + lastOffsetY: readonly(lastOffsetY) + } +} diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts new file mode 100644 index 000000000..ea275e220 --- /dev/null +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -0,0 +1,246 @@ +/** + * Vue Node Lifecycle Management Composable + * + * Handles the complete lifecycle of Vue node rendering system including: + * - Node manager initialization and cleanup + * - Layout store synchronization + * - Slot and link sync management + * - Reactive state management for node data, positions, and sizes + * - Memory management and proper cleanup + */ +import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue' + +import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' +import type { + NodeState, + VueNodeData +} from '@/composables/graph/useGraphNodeManager' +import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync' +import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync' +import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync' +import { app as comfyApp } from '@/scripts/app' +import { useCanvasStore } from '@/stores/graphStore' + +export function useVueNodeLifecycle(isVueNodesEnabled: Ref) { + const canvasStore = useCanvasStore() + const layoutMutations = useLayoutMutations() + + const nodeManager = shallowRef | null>( + null + ) + const cleanupNodeManager = shallowRef<(() => void) | null>(null) + + // Sync management + const slotSync = shallowRef | null>(null) + const slotSyncStarted = ref(false) + const linkSync = shallowRef | null>(null) + + // Vue node data state + const vueNodeData = ref>(new Map()) + const nodeState = ref>(new Map()) + const nodePositions = ref>( + new Map() + ) + const nodeSizes = ref>( + new Map() + ) + + // Change detection function + const detectChangesInRAF = ref<() => void>(() => {}) + + // Trigger for forcing computed re-evaluation + const nodeDataTrigger = ref(0) + + const isNodeManagerReady = computed(() => nodeManager.value !== null) + + const initializeNodeManager = () => { + if (!comfyApp.graph || nodeManager.value) return + + // Initialize the core node manager + const manager = useGraphNodeManager(comfyApp.graph) + nodeManager.value = manager + cleanupNodeManager.value = manager.cleanup + + // Use the manager's data maps + vueNodeData.value = manager.vueNodeData + nodeState.value = manager.nodeState + nodePositions.value = manager.nodePositions + nodeSizes.value = manager.nodeSizes + detectChangesInRAF.value = manager.detectChangesInRAF + + // Initialize layout system with existing nodes + const nodes = comfyApp.graph._nodes.map((node: LGraphNode) => ({ + id: node.id.toString(), + pos: [node.pos[0], node.pos[1]] as [number, number], + size: [node.size[0], node.size[1]] as [number, number] + })) + layoutStore.initializeFromLiteGraph(nodes) + + // Seed reroutes into the Layout Store so hit-testing uses the new path + for (const reroute of comfyApp.graph.reroutes.values()) { + const [x, y] = reroute.pos + const parent = reroute.parentId ?? undefined + const linkIds = Array.from(reroute.linkIds) + layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds) + } + + // Seed existing links into the Layout Store (topology only) + for (const link of comfyApp.graph._links.values()) { + layoutMutations.createLink( + link.id, + link.origin_id, + link.origin_slot, + link.target_id, + link.target_slot + ) + } + + // Initialize layout sync (one-way: Layout Store → LiteGraph) + const { startSync } = useLayoutSync() + startSync(canvasStore.canvas) + + // Initialize link layout sync for event-driven updates + const linkSyncManager = useLinkLayoutSync() + linkSync.value = linkSyncManager + if (comfyApp.canvas) { + linkSyncManager.start(comfyApp.canvas) + } + + // Force computed properties to re-evaluate + nodeDataTrigger.value++ + } + + const disposeNodeManagerAndSyncs = () => { + if (!nodeManager.value) return + + try { + cleanupNodeManager.value?.() + } catch { + /* empty */ + } + nodeManager.value = null + cleanupNodeManager.value = null + + // Clean up link layout sync + if (linkSync.value) { + linkSync.value.stop() + linkSync.value = null + } + + // Reset reactive maps to clean state + vueNodeData.value = new Map() + nodeState.value = new Map() + nodePositions.value = new Map() + nodeSizes.value = new Map() + + // Reset change detection function + detectChangesInRAF.value = () => {} + } + + // Watch for Vue nodes enabled state changes + watch( + () => isVueNodesEnabled.value && Boolean(comfyApp.graph), + (enabled) => { + if (enabled) { + initializeNodeManager() + } else { + disposeNodeManagerAndSyncs() + } + }, + { immediate: true } + ) + + // Consolidated watch for slot layout sync management + watch( + [() => canvasStore.canvas, () => isVueNodesEnabled.value], + ([canvas, vueMode], [, oldVueMode]) => { + const modeChanged = vueMode !== oldVueMode + + // Clear stale slot layouts when switching modes + if (modeChanged) { + layoutStore.clearAllSlotLayouts() + } + + // Switching to Vue + if (vueMode && slotSyncStarted.value) { + slotSync.value?.stop() + slotSyncStarted.value = false + } + + // Switching to LG + const shouldRun = Boolean(canvas?.graph) && !vueMode + if (shouldRun && !slotSyncStarted.value && canvas) { + // Initialize slot sync if not already created + if (!slotSync.value) { + slotSync.value = useSlotLayoutSync() + } + const started = slotSync.value.attemptStart(canvas as LGraphCanvas) + slotSyncStarted.value = started + } + }, + { immediate: true } + ) + + // Handle case where Vue nodes are enabled but graph starts empty + const setupEmptyGraphListener = () => { + if ( + isVueNodesEnabled.value && + comfyApp.graph && + !nodeManager.value && + comfyApp.graph._nodes.length === 0 + ) { + const originalOnNodeAdded = comfyApp.graph.onNodeAdded + comfyApp.graph.onNodeAdded = function (node: LGraphNode) { + // Restore original handler + comfyApp.graph.onNodeAdded = originalOnNodeAdded + + // Initialize node manager if needed + if (isVueNodesEnabled.value && !nodeManager.value) { + initializeNodeManager() + } + + // Call original handler + if (originalOnNodeAdded) { + originalOnNodeAdded.call(this, node) + } + } + } + } + + // Cleanup function for component unmounting + const cleanup = () => { + if (nodeManager.value) { + nodeManager.value.cleanup() + nodeManager.value = null + } + if (slotSyncStarted.value) { + slotSync.value?.stop() + slotSyncStarted.value = false + } + slotSync.value = null + if (linkSync.value) { + linkSync.value.stop() + linkSync.value = null + } + } + + return { + vueNodeData, + nodeState, + nodePositions, + nodeSizes, + nodeDataTrigger: readonly(nodeDataTrigger), + nodeManager: readonly(nodeManager), + detectChangesInRAF: readonly(detectChangesInRAF), + isNodeManagerReady, + + // Lifecycle methods + initializeNodeManager, + disposeNodeManagerAndSyncs, + setupEmptyGraphListener, + cleanup + } +}