diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue
index b8809fcd1..27d6c9f0e 100644
--- a/src/components/graph/GraphCanvas.vue
+++ b/src/components/graph/GraphCanvas.vue
@@ -59,122 +59,21 @@
/>
-
-
import type { LGraphCanvas } from '@comfyorg/litegraph'
-import { onMounted, onUnmounted, provide, ref } from 'vue'
+import { computed, provide } from 'vue'
import { useTransformState } from '@/composables/element/useTransformState'
+import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
+import { useTransformSettling } from '@/composables/graph/useTransformSettling'
interface TransformPaneProps {
canvas?: LGraphCanvas
@@ -68,10 +70,15 @@ const {
isNodeInViewport
} = useTransformState()
-// Interaction state
-const isInteracting = ref(false)
-let interactionTimeout: number | null = null
-let wheelTimeout: number | null = null
+// Transform settling detection for re-rasterization optimization
+const canvasElement = computed(() => props.canvas?.canvas)
+const { isTransforming } = useTransformSettling(canvasElement, {
+ settleDelay: 200,
+ trackPan: true
+})
+
+// Use isTransforming for the CSS class (aliased for clarity)
+const isInteracting = isTransforming
// Provide transform utilities to child components
provide('transformState', {
@@ -81,24 +88,6 @@ provide('transformState', {
isNodeInViewport
})
-// Handle will-change for performance
-// This adds/removes "will-change: transform" CSS property to optimize GPU rendering during interactions
-const setInteracting = (interactive: boolean) => {
- isInteracting.value = interactive
-
- if (!interactive && interactionTimeout !== null) {
- clearTimeout(interactionTimeout)
- interactionTimeout = null
- }
-
- if (!interactive) {
- // Delay removing will-change to avoid thrashing
- interactionTimeout = window.setTimeout(() => {
- isInteracting.value = false
- }, 200)
- }
-}
-
// Event delegation for node interactions
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement
@@ -110,117 +99,16 @@ const handlePointerDown = (event: PointerEvent) => {
}
}
-// Sync with canvas on RAF
-let rafId: number | null = null
+// Canvas transform synchronization
const emit = defineEmits<{
rafStatusChange: [active: boolean]
transformUpdate: [time: number]
}>()
-const startSync = () => {
- emit('rafStatusChange', true)
- const sync = () => {
- if (props.canvas) {
- const startTime = performance.now()
- syncWithCanvas(props.canvas)
- const endTime = performance.now()
- emit('transformUpdate', endTime - startTime)
- }
- rafId = requestAnimationFrame(sync)
- }
- sync()
-}
-
-const stopSync = () => {
- if (rafId !== null) {
- cancelAnimationFrame(rafId)
- rafId = null
- emit('rafStatusChange', false)
- }
-}
-
-// Canvas event listeners
-const handleWheel = () => {
- // Clear any existing wheel timeout
- if (wheelTimeout !== null) {
- clearTimeout(wheelTimeout)
- }
-
- // Start interaction if not already active
- if (!isInteracting.value) {
- setInteracting(true)
- }
-
- // Set timeout to end interaction after wheel stops
- wheelTimeout = window.setTimeout(() => {
- setInteracting(false)
- wheelTimeout = null
- }, 150) // 150ms after last wheel event
-}
-
-const handleCanvasInteractionStart = () => {
- setInteracting(true)
-}
-
-const handleCanvasInteractionEnd = () => {
- setInteracting(false)
-}
-
-onMounted(() => {
- startSync()
-
- // Listen to canvas interaction events if available
- if (props.canvas && props.canvas.canvas) {
- // Use capture phase (true) to intercept events before LiteGraph
- props.canvas.canvas.addEventListener('wheel', handleWheel, true)
- props.canvas.canvas.addEventListener(
- 'pointerdown',
- handleCanvasInteractionStart,
- true
- )
- props.canvas.canvas.addEventListener(
- 'pointerup',
- handleCanvasInteractionEnd,
- true
- )
- props.canvas.canvas.addEventListener(
- 'pointercancel',
- handleCanvasInteractionEnd,
- true
- )
- }
-})
-
-onUnmounted(() => {
- stopSync()
-
- if (interactionTimeout !== null) {
- clearTimeout(interactionTimeout)
- }
-
- if (wheelTimeout !== null) {
- clearTimeout(wheelTimeout)
- }
-
- // Clean up event listeners (must match capture phase)
- if (props.canvas && props.canvas.canvas) {
- props.canvas.canvas.removeEventListener('wheel', handleWheel, true)
- props.canvas.canvas.removeEventListener(
- 'pointerdown',
- handleCanvasInteractionStart,
- true
- )
- props.canvas.canvas.removeEventListener(
- 'pointerup',
- handleCanvasInteractionEnd,
- true
- )
- props.canvas.canvas.removeEventListener(
- 'pointercancel',
- handleCanvasInteractionEnd,
- true
- )
- }
+useCanvasTransformSync(props.canvas, syncWithCanvas, {
+ onStart: () => emit('rafStatusChange', true),
+ onUpdate: (duration) => emit('transformUpdate', duration),
+ onStop: () => emit('rafStatusChange', false)
})
diff --git a/src/components/graph/debug/VueNodeDebugPanel.vue b/src/components/graph/debug/VueNodeDebugPanel.vue
new file mode 100644
index 000000000..903071dba
--- /dev/null
+++ b/src/components/graph/debug/VueNodeDebugPanel.vue
@@ -0,0 +1,164 @@
+
+
+
+
TransformPane Debug
+
+
+
+
+
+
+
+
Canvas State
+
+ Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }}
+
+
+ Viewport: {{ Math.round(canvasViewport.width) }}x{{
+ Math.round(canvasViewport.height)
+ }}
+
+
+
+ Offset: ({{ Math.round(canvasStore.canvas.ds.offset[0]) }},
+ {{ Math.round(canvasStore.canvas.ds.offset[1]) }})
+
+
+ Scale: {{ canvasStore.canvas.ds.scale?.toFixed(3) || 1 }}
+
+
+
+
+
+
+
Graph Metrics
+
+ Total Nodes: {{ comfyApp.graph?.nodes?.length || 0 }}
+
+
+ Selected Nodes: {{ canvasStore.canvas?.selectedItems?.size || 0 }}
+
+
Vue Nodes Rendered: {{ vueNodesCount }}
+
Nodes in Viewport: {{ nodesInViewport }}
+
+ Culled Nodes: {{ performanceMetrics.culledCount }}
+
+
+ Cull Percentage:
+ {{
+ Math.round(
+ ((vueNodesCount - nodesInViewport) / Math.max(vueNodesCount, 1)) *
+ 100
+ )
+ }}%
+
+
+
+
+
+
Performance
+
FPS: {{ currentFPS }}
+
+ Transform Update: {{ Math.round(lastTransformTime) }}ms
+
+
+ Lifecycle Update: {{ Math.round(performanceMetrics.updateTime) }}ms
+
+
+ RAF Active: {{ rafActive ? 'Yes' : 'No' }}
+
+
+ Adaptive Quality:
+ {{ performanceMetrics.adaptiveQuality ? 'On' : 'Off' }}
+
+
+
+
+
+
Feature Flags
+
+ Vue Nodes: {{ shouldRenderVueNodes ? 'Enabled' : 'Disabled' }}
+
+
+ Dev Mode: {{ isDevModeEnabled ? 'Enabled' : 'Disabled' }}
+
+
+
+
+
+
Debug Options
+
+
+
+
+
+
+
diff --git a/src/composables/graph/useCanvasTransformSync.ts b/src/composables/graph/useCanvasTransformSync.ts
new file mode 100644
index 000000000..00fc9866f
--- /dev/null
+++ b/src/composables/graph/useCanvasTransformSync.ts
@@ -0,0 +1,114 @@
+import type { LGraphCanvas } from '@comfyorg/litegraph'
+import { onUnmounted, ref } from 'vue'
+
+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
+ }
+}
diff --git a/src/composables/graph/useTransformSettling.ts b/src/composables/graph/useTransformSettling.ts
new file mode 100644
index 000000000..669cfceaa
--- /dev/null
+++ b/src/composables/graph/useTransformSettling.ts
@@ -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
,
+ 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
+ }
+}