[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.
This commit is contained in:
bymyself
2025-07-05 21:06:45 -07:00
parent 555e806f1e
commit 9a93764cc8
5 changed files with 463 additions and 246 deletions

View File

@@ -59,122 +59,21 @@
/>
</TransformPane>
<!-- TransformPane Debug Controls -->
<div
class="fixed top-20 right-4 bg-surface-0 dark-theme:bg-surface-800 p-4 rounded-lg shadow-lg border border-surface-300 dark-theme:border-surface-600 z-50 pointer-events-auto w-80"
style="contain: layout style"
>
<h3 class="font-bold mb-2 text-sm">TransformPane Debug</h3>
<div class="space-y-2 text-xs">
<div>
<label class="flex items-center gap-2">
<input v-model="debugOverrideVueNodes" type="checkbox" />
<span>Enable TransformPane</span>
</label>
</div>
<!-- Canvas Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Canvas State</h4>
<p class="text-muted">
Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }}
</p>
<p class="text-muted">
Viewport: {{ Math.round(canvasViewport.width) }}x{{
Math.round(canvasViewport.height)
}}
</p>
<template v-if="canvasStore.canvas?.ds">
<p class="text-muted">
Offset: ({{ Math.round(canvasStore.canvas.ds.offset[0]) }},
{{ Math.round(canvasStore.canvas.ds.offset[1]) }})
</p>
<p class="text-muted">
Scale: {{ canvasStore.canvas.ds.scale?.toFixed(3) || 1 }}
</p>
</template>
</div>
<!-- Node Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Graph Metrics</h4>
<p class="text-muted">
Total Nodes: {{ comfyApp.graph?.nodes?.length || 0 }}
</p>
<p class="text-muted">
Selected Nodes: {{ canvasStore.canvas?.selectedItems?.size || 0 }}
</p>
<p class="text-muted">Vue Nodes Rendered: {{ vueNodesCount }}</p>
<p class="text-muted">Nodes in Viewport: {{ nodesInViewport }}</p>
<p class="text-muted">
Culled Nodes: {{ performanceMetrics.culledCount }}
</p>
<p class="text-muted">
Cull Percentage:
{{
Math.round(
((vueNodesCount - nodesInViewport) / Math.max(vueNodesCount, 1)) *
100
)
}}%
</p>
</div>
<!-- Performance Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Performance</h4>
<p v-memo="[currentFPS]" class="text-muted">FPS: {{ currentFPS }}</p>
<p v-memo="[Math.round(lastTransformTime)]" class="text-muted">
Transform Update: {{ Math.round(lastTransformTime) }}ms
</p>
<p
v-memo="[Math.round(performanceMetrics.updateTime)]"
class="text-muted"
>
Lifecycle Update: {{ Math.round(performanceMetrics.updateTime) }}ms
</p>
<p v-memo="[rafActive]" class="text-muted">
RAF Active: {{ rafActive ? 'Yes' : 'No' }}
</p>
<p v-memo="[performanceMetrics.adaptiveQuality]" class="text-muted">
Adaptive Quality:
{{ performanceMetrics.adaptiveQuality ? 'On' : 'Off' }}
</p>
</div>
<!-- Feature Flags Status -->
<div
v-if="isDevModeEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Feature Flags</h4>
<p class="text-muted text-xs">
Vue Nodes: {{ shouldRenderVueNodes ? 'Enabled' : 'Disabled' }}
</p>
<p class="text-muted text-xs">
Dev Mode: {{ isDevModeEnabled ? 'Enabled' : 'Disabled' }}
</p>
</div>
<!-- Performance Options -->
<div
v-if="transformPaneEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Debug Options</h4>
<label class="flex items-center gap-2">
<input v-model="showPerformanceOverlay" type="checkbox" />
<span>Show Performance Overlay</span>
</label>
</div>
</div>
</div>
<!-- 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 />
@@ -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'

View File

@@ -8,7 +8,7 @@
<!-- Vue nodes will be rendered here -->
<slot />
<!-- Debug: Viewport bounds visualization -->
<!-- DEV ONLY: Viewport bounds visualization -->
<div
v-if="props.showDebugOverlay"
class="viewport-debug-overlay"
@@ -43,9 +43,11 @@
<script setup lang="ts">
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)
})
</script>

View File

@@ -0,0 +1,164 @@
<template>
<!-- TransformPane Debug Controls -->
<div
class="fixed top-20 right-4 bg-surface-0 dark-theme:bg-surface-800 p-4 rounded-lg shadow-lg border border-surface-300 dark-theme:border-surface-600 z-50 pointer-events-auto w-80"
style="contain: layout style"
>
<h3 class="font-bold mb-2 text-sm">TransformPane Debug</h3>
<div class="space-y-2 text-xs">
<div>
<label class="flex items-center gap-2">
<input v-model="debugOverrideVueNodes" type="checkbox" />
<span>Enable TransformPane</span>
</label>
</div>
<!-- Canvas Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Canvas State</h4>
<p class="text-muted">
Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }}
</p>
<p class="text-muted">
Viewport: {{ Math.round(canvasViewport.width) }}x{{
Math.round(canvasViewport.height)
}}
</p>
<template v-if="canvasStore.canvas?.ds">
<p class="text-muted">
Offset: ({{ Math.round(canvasStore.canvas.ds.offset[0]) }},
{{ Math.round(canvasStore.canvas.ds.offset[1]) }})
</p>
<p class="text-muted">
Scale: {{ canvasStore.canvas.ds.scale?.toFixed(3) || 1 }}
</p>
</template>
</div>
<!-- Node Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Graph Metrics</h4>
<p class="text-muted">
Total Nodes: {{ comfyApp.graph?.nodes?.length || 0 }}
</p>
<p class="text-muted">
Selected Nodes: {{ canvasStore.canvas?.selectedItems?.size || 0 }}
</p>
<p class="text-muted">Vue Nodes Rendered: {{ vueNodesCount }}</p>
<p class="text-muted">Nodes in Viewport: {{ nodesInViewport }}</p>
<p class="text-muted">
Culled Nodes: {{ performanceMetrics.culledCount }}
</p>
<p class="text-muted">
Cull Percentage:
{{
Math.round(
((vueNodesCount - nodesInViewport) / Math.max(vueNodesCount, 1)) *
100
)
}}%
</p>
</div>
<!-- Performance Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Performance</h4>
<p v-memo="[currentFPS]" class="text-muted">FPS: {{ currentFPS }}</p>
<p v-memo="[Math.round(lastTransformTime)]" class="text-muted">
Transform Update: {{ Math.round(lastTransformTime) }}ms
</p>
<p
v-memo="[Math.round(performanceMetrics.updateTime)]"
class="text-muted"
>
Lifecycle Update: {{ Math.round(performanceMetrics.updateTime) }}ms
</p>
<p v-memo="[rafActive]" class="text-muted">
RAF Active: {{ rafActive ? 'Yes' : 'No' }}
</p>
<p v-memo="[performanceMetrics.adaptiveQuality]" class="text-muted">
Adaptive Quality:
{{ performanceMetrics.adaptiveQuality ? 'On' : 'Off' }}
</p>
</div>
<!-- Feature Flags Status -->
<div
v-if="isDevModeEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Feature Flags</h4>
<p class="text-muted text-xs">
Vue Nodes: {{ shouldRenderVueNodes ? 'Enabled' : 'Disabled' }}
</p>
<p class="text-muted text-xs">
Dev Mode: {{ isDevModeEnabled ? 'Enabled' : 'Disabled' }}
</p>
</div>
<!-- Performance Options -->
<div
v-if="transformPaneEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Debug Options</h4>
<label class="flex items-center gap-2">
<input v-model="showPerformanceOverlay" type="checkbox" />
<span>Show Performance Overlay</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
interface Props {
debugOverrideVueNodes: boolean
canvasViewport: { width: number; height: number }
vueNodesCount: number
nodesInViewport: number
performanceMetrics: {
culledCount: number
updateTime: number
adaptiveQuality: boolean
}
currentFPS: number
lastTransformTime: number
rafActive: boolean
isDevModeEnabled: boolean
shouldRenderVueNodes: boolean
transformPaneEnabled: boolean
showPerformanceOverlay: boolean
}
interface Emits {
(e: 'update:debugOverrideVueNodes', value: boolean): void
(e: 'update:showPerformanceOverlay', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const canvasStore = useCanvasStore()
const debugOverrideVueNodes = computed({
get: () => props.debugOverrideVueNodes,
set: (value: boolean) => emit('update:debugOverrideVueNodes', value)
})
const showPerformanceOverlay = computed({
get: () => props.showPerformanceOverlay,
set: (value: boolean) => emit('update:showPerformanceOverlay', value)
})
</script>

View File

@@ -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
}
}

View File

@@ -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<HTMLElement | null | undefined>,
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
}
}