mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 08:30:06 +00:00
[feat] Add TransformPane for Vue node coordinate synchronization
This commit is contained in:
128
src/components/graph/TestTransformPane.vue
Normal file
128
src/components/graph/TestTransformPane.vue
Normal 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>
|
||||
167
src/components/graph/TransformPane.vue
Normal file
167
src/components/graph/TransformPane.vue
Normal 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>
|
||||
102
src/composables/element/useTransformState.ts
Normal file
102
src/composables/element/useTransformState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user