[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:
Christian Byrne
2025-08-06 11:51:41 -07:00
committed by Benjamin Lu
parent d488e59a2a
commit 2ab4fb79ee
119 changed files with 12658 additions and 397 deletions

View File

@@ -34,6 +34,54 @@
class="w-full h-full touch-none"
/>
<!-- TransformPane for Vue node rendering (development) -->
<TransformPane
v-if="transformPaneEnabled && canvasStore.canvas && comfyAppReady"
:canvas="canvasStore.canvas as LGraphCanvas"
:viewport="canvasViewport"
:show-debug-overlay="showPerformanceOverlay"
@raf-status-change="rafActive = $event"
@transform-update="handleTransformUpdate"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in nodesToRender"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:selected="nodeData.selected"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
<!-- Debug Panel (Development Only) -->
<VueNodeDebugPanel
v-model:debug-override-vue-nodes="debugOverrideVueNodes"
v-model:show-performance-overlay="showPerformanceOverlay"
:canvas-viewport="canvasViewport"
:vue-nodes-count="vueNodesCount"
:nodes-in-viewport="nodesInViewport"
:performance-metrics="performanceMetrics"
:current-f-p-s="currentFPS"
:last-transform-time="lastTransformTime"
:raf-active="rafActive"
:is-dev-mode-enabled="isDevModeEnabled"
:should-render-vue-nodes="shouldRenderVueNodes"
:transform-pane-enabled="transformPaneEnabled"
/>
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
@@ -50,7 +98,16 @@
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import {
computed,
onMounted,
onUnmounted,
reactive,
ref,
shallowRef,
watch,
watchEffect
} from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -61,14 +118,24 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import TransformPane from '@/components/graph/TransformPane.vue'
import VueNodeDebugPanel from '@/components/graph/debug/VueNodeDebugPanel.vue'
import VueGraphNode from '@/components/graph/vueNodes/LGraphNode.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
@@ -77,7 +144,7 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -123,6 +190,311 @@ const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
// Feature flags
const { shouldRenderVueNodes, isDevModeEnabled } = useFeatureFlags()
// TransformPane enabled when Vue nodes are enabled OR debug override
const debugOverrideVueNodes = ref(true) // Default to true for development
const transformPaneEnabled = computed(
() => shouldRenderVueNodes.value || debugOverrideVueNodes.value
)
// Account for browser zoom/DPI scaling
const getActualViewport = () => {
// Get the actual canvas element dimensions which account for zoom
const canvas = canvasRef.value
if (canvas) {
return {
width: canvas.clientWidth,
height: canvas.clientHeight
}
}
// Fallback to window dimensions
return {
width: window.innerWidth,
height: window.innerHeight
}
}
const canvasViewport = ref(getActualViewport())
// Debug metrics - use shallowRef for frequently updating values
const vueNodesCount = shallowRef(0)
const nodesInViewport = shallowRef(0)
const currentFPS = shallowRef(0)
const lastTransformTime = shallowRef(0)
const rafActive = shallowRef(false)
// Rendering options
const showPerformanceOverlay = ref(false)
// FPS tracking
let lastTime = performance.now()
let frameCount = 0
let fpsRafId: number | null = null
const updateFPS = () => {
frameCount++
const currentTime = performance.now()
if (currentTime >= lastTime + 1000) {
currentFPS.value = Math.round(
(frameCount * 1000) / (currentTime - lastTime)
)
frameCount = 0
lastTime = currentTime
}
if (transformPaneEnabled.value) {
fpsRafId = requestAnimationFrame(updateFPS)
}
}
// Start FPS tracking when TransformPane is enabled
watch(transformPaneEnabled, (enabled) => {
if (enabled) {
fpsRafId = requestAnimationFrame(updateFPS)
} else {
// Stop FPS tracking
if (fpsRafId !== null) {
cancelAnimationFrame(fpsRafId)
fpsRafId = null
}
}
})
// Update viewport on resize
useEventListener(window, 'resize', () => {
canvasViewport.value = getActualViewport()
})
// Also update when canvas is ready
watch(canvasRef, () => {
if (canvasRef.value) {
canvasViewport.value = getActualViewport()
}
})
// Vue node lifecycle management - initialize after graph is ready
let nodeManager: ReturnType<typeof useGraphNodeManager> | 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()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
let detectChangesInRAF = () => {}
const performanceMetrics = reactive({
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
adaptiveQuality: false
})
// 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)
// 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
Object.assign(performanceMetrics, nodeManager.performanceMetrics)
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
// Watch for graph availability
watch(
() => comfyApp.graph,
(graph) => {
if (graph) {
initializeNodeManager()
}
},
{ immediate: true }
)
// Transform state for viewport culling
const { syncWithCanvas } = useTransformState()
// Replace problematic computed property with proper reactive system
const nodesToRender = computed(() => {
// Access performanceMetrics to trigger on RAF updates
void performanceMetrics.updateTime
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph || !transformPaneEnabled.value) {
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
// Access transform time to make this reactive to transform changes
void lastTransformTime.value
// 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
})
// Remove side effects from computed - use watchers instead
watch(
() => vueNodeData.value.size,
(count) => {
vueNodesCount.value = count
},
{ immediate: true }
)
watch(
() => nodesToRender.value.length,
(count) => {
nodesInViewport.value = count
}
)
// Update performance metrics when node counts change
watch(
() => [vueNodeData.value.size, nodesToRender.value.length],
([totalNodes, visibleNodes]) => {
performanceMetrics.nodeCount = totalNodes
performanceMetrics.culledCount = totalNodes - visibleNodes
}
)
// Integrate change detection with TransformPane RAF
// Track previous transform to detect changes
let lastScale = 1
let lastOffsetX = 0
let lastOffsetY = 0
const handleTransformUpdate = (time: number) => {
lastTransformTime.value = time
// 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()
// Update performance metrics
performanceMetrics.frameTime = time
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)
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
}
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -274,7 +646,7 @@ const loadCustomNodesI18n = async () => {
i18n.global.mergeLocaleMessage(locale, message)
})
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
// Ignore i18n loading errors - not critical
}
}
@@ -291,6 +663,7 @@ onMounted(async () => {
useCopy()
usePaste()
useWorkflowAutoSave()
useFeatureFlags() // This will automatically sync Vue nodes flag with LiteGraph
comfyApp.vueAppReady = true
@@ -303,9 +676,6 @@ onMounted(async () => {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log(
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
)
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
@@ -330,6 +700,11 @@ onMounted(async () => {
comfyAppReady.value = true
// Initialize node manager after setup is complete
if (comfyApp.graph) {
initializeNodeManager()
}
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
@@ -377,4 +752,18 @@ onMounted(async () => {
emit('ready')
})
onUnmounted(() => {
// Clean up FPS tracking
if (fpsRafId !== null) {
cancelAnimationFrame(fpsRafId)
fpsRafId = null
}
// Clean up node manager
if (nodeManager) {
nodeManager.cleanup()
nodeManager = null
}
})
</script>