[refactor] Extract Vue node entry point logic into focused composables (#5390)

* refactor: Extract Vue node entry point logic into focused composables

- Extract logic from GraphCanvas.vue (735→200 lines) into 3 composables:
  - useVueNodeLifecycle: Node manager initialization and cleanup
  - useViewportCulling: Viewport culling with transform sync
  - useNodeEventHandlers: Node selection, collapse, title handlers

- Remove type assertions by using comfyApp.canvas instead of canvasStore.canvas
- Eliminate getter anti-pattern with proper Vue reactive refs
- Fix all TypeScript compatibility issues without workarounds
- Maintain proper separation of concerns and Vue-idiomatic patterns

* style: Remove extra comments from return statement

* [auto-fix] Apply ESLint and Prettier fixes

* style: Remove conversational comments

- Remove temporary comments that only made sense in refactoring context
- Clean up comment wording to be more permanent/professional
- Keep meaningful comments about code behavior and architecture

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Christian Byrne
2025-09-06 04:26:23 -07:00
committed by snomiao
parent 6da987df4f
commit 268834f04e
5 changed files with 703 additions and 351 deletions

View File

@@ -33,8 +33,8 @@
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="isVueNodesEnabled && canvasStore.canvas && comfyAppReady"
:canvas="canvasStore.canvas as LGraphCanvas"
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@transform-update="handleTransformUpdate"
>
<!-- Vue nodes rendered based on graph nodes -->
@@ -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<typeof useGraphNodeManager> | null = null
let cleanupNodeManager: (() => void) | null = null
// Slot layout sync management
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
let slotSyncStarted = false
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
const viewportCulling = useViewportCulling(
isVueNodesEnabled,
vueNodeLifecycle.vueNodeData,
vueNodeLifecycle.nodeDataTrigger,
vueNodeLifecycle.nodeManager
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
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()
})
</script>

View File

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

View File

@@ -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<NodeManager | null>) {
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
}
}

View File

@@ -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<boolean>,
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
nodeDataTrigger: Ref<number>,
nodeManager: Ref<NodeManager | null>
) {
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)
}
}

View File

@@ -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<boolean>) {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>(
null
)
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
// Sync management
const slotSync = shallowRef<ReturnType<typeof useSlotLayoutSync> | null>(null)
const slotSyncStarted = ref(false)
const linkSync = shallowRef<ReturnType<typeof useLinkLayoutSync> | null>(null)
// Vue node data state
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
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
}
}