From 9a93764cc8ae87d445485502b9ad95f00fe47709 Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 5 Jul 2025 21:06:45 -0700 Subject: [PATCH] [refactor] Extract canvas transform sync to dedicated composables - Create useCanvasTransformSync for clean RAF-based transform synchronization - Add useTransformSettling for detecting when transforms have stabilized - Refactor TransformPane to use extracted composables - Update GraphCanvas to use new transform sync composable - Add VueNodeDebugPanel for transform visualization and debugging - Improve separation of concerns and reusability This refactoring makes the transform sync logic more maintainable and testable while preserving all existing functionality. --- src/components/graph/GraphCanvas.vue | 132 ++------------ src/components/graph/TransformPane.vue | 148 ++-------------- .../graph/debug/VueNodeDebugPanel.vue | 164 ++++++++++++++++++ .../graph/useCanvasTransformSync.ts | 114 ++++++++++++ src/composables/graph/useTransformSettling.ts | 151 ++++++++++++++++ 5 files changed, 463 insertions(+), 246 deletions(-) create mode 100644 src/components/graph/debug/VueNodeDebugPanel.vue create mode 100644 src/composables/graph/useCanvasTransformSync.ts create mode 100644 src/composables/graph/useTransformSettling.ts 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 @@ /> - -
-

TransformPane Debug

-
-
- -
- - -
-

Canvas State

-

- Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }} -

-

- Viewport: {{ Math.round(canvasViewport.width) }}x{{ - Math.round(canvasViewport.height) - }} -

- -
- - -
-

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

- -
-
-
+ + @@ -214,6 +113,7 @@ 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' diff --git a/src/components/graph/TransformPane.vue b/src/components/graph/TransformPane.vue index 733138e8a..d5cc0f4e2 100644 --- a/src/components/graph/TransformPane.vue +++ b/src/components/graph/TransformPane.vue @@ -8,7 +8,7 @@ - +
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 @@ + + + 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 + } +}