mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +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