[feat] Add TransformPane for Vue node coordinate synchronization

This commit is contained in:
bymyself
2025-06-29 20:13:20 -07:00
parent a041f40fb5
commit 065e292b1c
3 changed files with 397 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
<template>
<div class="relative w-full h-full bg-gray-100">
<!-- Canvas placeholder -->
<canvas ref="canvasRef" class="absolute inset-0 w-full h-full" />
<!-- Transform Pane -->
<TransformPane :canvas="mockCanvas" :viewport="viewport">
<!-- Test nodes -->
<div
v-for="node in testNodes"
:key="node.id"
:data-node-id="node.id"
class="absolute border-2 border-blue-500 bg-white p-4 rounded shadow-lg"
:style="{
left: node.pos[0] + 'px',
top: node.pos[1] + 'px',
width: node.size[0] + 'px',
height: node.size[1] + 'px'
}"
>
<h3 class="font-bold">{{ node.title }}</h3>
<p class="text-sm text-gray-600">ID: {{ node.id }}</p>
</div>
</TransformPane>
<!-- Controls -->
<div class="absolute top-4 left-4 bg-white p-4 rounded shadow-lg">
<h2 class="font-bold mb-2">Transform Controls</h2>
<div class="space-y-2">
<button
class="px-3 py-1 bg-blue-500 text-white rounded"
@click="pan(-50, 0)"
>
Pan Left
</button>
<button
class="px-3 py-1 bg-blue-500 text-white rounded"
@click="pan(50, 0)"
>
Pan Right
</button>
<button
class="px-3 py-1 bg-blue-500 text-white rounded"
@click="pan(0, -50)"
>
Pan Up
</button>
<button
class="px-3 py-1 bg-blue-500 text-white rounded"
@click="pan(0, 50)"
>
Pan Down
</button>
<button
class="px-3 py-1 bg-green-500 text-white rounded"
@click="zoom(1.2)"
>
Zoom In
</button>
<button
class="px-3 py-1 bg-green-500 text-white rounded"
@click="zoom(0.8)"
>
Zoom Out
</button>
<button
class="px-3 py-1 bg-gray-500 text-white rounded"
@click="reset()"
>
Reset
</button>
</div>
<div class="mt-4 text-sm">
<p>
Offset: {{ mockCanvas.ds.offset[0].toFixed(1) }},
{{ mockCanvas.ds.offset[1].toFixed(1) }}
</p>
<p>Scale: {{ mockCanvas.ds.scale.toFixed(2) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import TransformPane from './TransformPane.vue'
// Mock canvas with transform state
const mockCanvas = reactive({
ds: {
offset: [0, 0] as [number, number],
scale: 1
},
canvas: document.createElement('canvas')
}) as any // Using any for mock object
const canvasRef = ref<HTMLCanvasElement>()
const viewport = ref(new DOMRect(0, 0, window.innerWidth, window.innerHeight))
// Test nodes
const testNodes = ref([
{ id: '1', title: 'Node 1', pos: [100, 100], size: [200, 100] },
{ id: '2', title: 'Node 2', pos: [350, 150], size: [200, 100] },
{ id: '3', title: 'Node 3', pos: [200, 300], size: [200, 100] }
])
// Transform controls
const pan = (dx: number, dy: number) => {
mockCanvas.ds.offset[0] += dx
mockCanvas.ds.offset[1] += dy
}
const zoom = (factor: number) => {
mockCanvas.ds.scale *= factor
}
const reset = () => {
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
}
onMounted(() => {
if (canvasRef.value) {
mockCanvas.canvas = canvasRef.value
}
})
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
<!-- Vue nodes will be rendered here -->
<slot />
</div>
</template>
<script setup lang="ts">
import type { LGraphCanvas } from '@comfyorg/litegraph'
import { onMounted, onUnmounted, provide, ref } from 'vue'
import { useTransformState } from '@/composables/element/useTransformState'
interface TransformPaneProps {
canvas?: LGraphCanvas
viewport?: DOMRect
}
const props = defineProps<TransformPaneProps>()
// Transform state management
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
// Interaction state
const isInteracting = ref(false)
let interactionTimeout: number | null = null
// Provide transform utilities to child components
provide('transformState', {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
// Handle will-change for performance
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
const nodeElement = target.closest('[data-node-id]')
if (nodeElement) {
const nodeId = nodeElement.getAttribute('data-node-id')
// TODO: Emit event for node interaction
console.log('Node interaction:', nodeId)
}
}
// Sync with canvas on RAF
let rafId: number | null = null
const startSync = () => {
const sync = () => {
if (props.canvas) {
syncWithCanvas(props.canvas)
}
rafId = requestAnimationFrame(sync)
}
sync()
}
const stopSync = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// Canvas event listeners
const handleCanvasInteractionStart = () => setInteracting(true)
const handleCanvasInteractionEnd = () => setInteracting(false)
onMounted(() => {
startSync()
// Listen to canvas interaction events if available
if (props.canvas) {
props.canvas.canvas.addEventListener('wheel', handleCanvasInteractionStart)
props.canvas.canvas.addEventListener(
'pointerdown',
handleCanvasInteractionStart
)
props.canvas.canvas.addEventListener(
'pointerup',
handleCanvasInteractionEnd
)
props.canvas.canvas.addEventListener(
'pointercancel',
handleCanvasInteractionEnd
)
}
})
onUnmounted(() => {
stopSync()
if (interactionTimeout !== null) {
clearTimeout(interactionTimeout)
}
// Clean up event listeners
if (props.canvas) {
props.canvas.canvas.removeEventListener(
'wheel',
handleCanvasInteractionStart
)
props.canvas.canvas.removeEventListener(
'pointerdown',
handleCanvasInteractionStart
)
props.canvas.canvas.removeEventListener(
'pointerup',
handleCanvasInteractionEnd
)
props.canvas.canvas.removeEventListener(
'pointercancel',
handleCanvasInteractionEnd
)
}
})
</script>
<style scoped>
.transform-pane {
position: absolute;
inset: 0;
contain: layout style paint;
transform-origin: 0 0;
pointer-events: none;
}
.transform-pane--interacting {
will-change: transform;
}
/* Allow pointer events on nodes */
.transform-pane :deep([data-node-id]) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,102 @@
/**
* Composable for managing transform state synchronized with LiteGraph canvas
* Provides reactive transform state and coordinate conversion utilities
*/
import type { LGraphCanvas } from '@comfyorg/litegraph'
import { computed, reactive, readonly } from 'vue'
export interface Point {
x: number
y: number
}
export interface Camera {
x: number
y: number
z: number // scale/zoom
}
export const useTransformState = () => {
// Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({
x: 0,
y: 0,
z: 1
})
// Computed transform string for CSS
const transformStyle = computed(() => ({
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
// Sync with LiteGraph during draw cycle
const syncWithCanvas = (canvas: LGraphCanvas) => {
if (!canvas || !canvas.ds) return
camera.x = canvas.ds.offset[0]
camera.y = canvas.ds.offset[1]
camera.z = canvas.ds.scale || 1
}
// Convert canvas coordinates to screen coordinates
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
y: point.y * camera.z + camera.y
}
}
// Convert screen coordinates to canvas coordinates
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
y: (point.y - camera.y) / camera.z
}
}
// Get node's screen bounds for culling
const getNodeScreenBounds = (
pos: [number, number],
size: [number, number]
): DOMRect => {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
return new DOMRect(topLeft.x, topLeft.y, width, height)
}
// Check if node is within viewport
const isNodeInViewport = (
nodePos: [number, number],
nodeSize: [number, number],
viewport: DOMRect,
margin: number = 0.2 // 20% margin by default
): boolean => {
const nodeBounds = getNodeScreenBounds(nodePos, nodeSize)
const expandedViewport = new DOMRect(
viewport.x - viewport.width * margin,
viewport.y - viewport.height * margin,
viewport.width * (1 + margin * 2),
viewport.height * (1 + margin * 2)
)
return !(
nodeBounds.right < expandedViewport.left ||
nodeBounds.left > expandedViewport.right ||
nodeBounds.bottom < expandedViewport.top ||
nodeBounds.top > expandedViewport.bottom
)
}
return {
camera: readonly(camera),
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
isNodeInViewport
}
}