mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-27 03:19:56 +00:00
## Summary Replicate the alt+drag to clone behavior present in litegraph. ## Changes - **What**: Simplify the interaction/drag handling, now with less state! - **What**: Alt+Click+Drag a node to clone it ## Screenshots (if applicable) https://github.com/user-attachments/assets/469e33c2-de0c-4e64-a344-1e9d9339d528 <!-- Add screenshots or video recording to help explain your changes --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6789-WIP-Alt-Drag-to-clone-Vue-Nodes-2b16d73d36508102a871ffe97ed2831f) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
|
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
|
|
|
// Create a mock canvas context for transform testing
|
|
function createMockCanvasContext() {
|
|
return {
|
|
canvas: {
|
|
width: 1280,
|
|
height: 720,
|
|
getBoundingClientRect: () => ({
|
|
left: 0,
|
|
top: 0,
|
|
width: 1280,
|
|
height: 720,
|
|
right: 1280,
|
|
bottom: 720,
|
|
x: 0,
|
|
y: 0
|
|
})
|
|
},
|
|
ds: {
|
|
offset: [0, 0],
|
|
scale: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('useTransformState', () => {
|
|
const transformState = useTransformState()
|
|
|
|
beforeEach(() => {
|
|
transformState.syncWithCanvas({
|
|
ds: { offset: [0, 0] }
|
|
} as unknown as LGraphCanvas)
|
|
})
|
|
|
|
describe('initial state', () => {
|
|
it('should initialize with default camera values', () => {
|
|
const { camera } = transformState
|
|
expect(camera.x).toBe(0)
|
|
expect(camera.y).toBe(0)
|
|
expect(camera.z).toBe(1)
|
|
})
|
|
|
|
it('should generate correct initial transform style', () => {
|
|
const { transformStyle } = transformState
|
|
expect(transformStyle.value).toEqual({
|
|
transform: 'scale(1) translate(0px, 0px)',
|
|
transformOrigin: '0 0'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('syncWithCanvas', () => {
|
|
it('should sync camera state with canvas transform', () => {
|
|
const { syncWithCanvas, camera } = transformState
|
|
const mockCanvas = createMockCanvasContext()
|
|
|
|
// Set mock canvas transform
|
|
mockCanvas.ds.offset = [100, 50]
|
|
mockCanvas.ds.scale = 2
|
|
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
expect(camera.x).toBe(100)
|
|
expect(camera.y).toBe(50)
|
|
expect(camera.z).toBe(2)
|
|
})
|
|
|
|
it('should handle null canvas gracefully', () => {
|
|
const { syncWithCanvas, camera } = transformState
|
|
|
|
syncWithCanvas(null as any)
|
|
|
|
// Should remain at initial values
|
|
expect(camera.x).toBe(0)
|
|
expect(camera.y).toBe(0)
|
|
expect(camera.z).toBe(1)
|
|
})
|
|
|
|
it('should handle canvas without ds property', () => {
|
|
const { syncWithCanvas, camera } = transformState
|
|
const canvasWithoutDs = { canvas: {} }
|
|
|
|
syncWithCanvas(canvasWithoutDs as any)
|
|
|
|
// Should remain at initial values
|
|
expect(camera.x).toBe(0)
|
|
expect(camera.y).toBe(0)
|
|
expect(camera.z).toBe(1)
|
|
})
|
|
|
|
it('should update transform style after sync', () => {
|
|
const { syncWithCanvas, transformStyle } = transformState
|
|
const mockCanvas = createMockCanvasContext()
|
|
|
|
mockCanvas.ds.offset = [150, 75]
|
|
mockCanvas.ds.scale = 0.5
|
|
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
expect(transformStyle.value).toEqual({
|
|
transform: 'scale(0.5) translate(150px, 75px)',
|
|
transformOrigin: '0 0'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('coordinate conversions', () => {
|
|
beforeEach(() => {
|
|
// Set up a known transform state
|
|
const mockCanvas = createMockCanvasContext()
|
|
mockCanvas.ds.offset = [100, 50]
|
|
mockCanvas.ds.scale = 2
|
|
transformState.syncWithCanvas(mockCanvas as any)
|
|
})
|
|
|
|
describe('canvasToScreen', () => {
|
|
it('should convert canvas coordinates to screen coordinates', () => {
|
|
const { canvasToScreen } = transformState
|
|
|
|
const canvasPoint = { x: 10, y: 20 }
|
|
const screenPoint = canvasToScreen(canvasPoint)
|
|
|
|
// screen = (canvas + offset) * scale
|
|
// x: (10 + 100) * 2 = 220
|
|
// y: (20 + 50) * 2 = 140
|
|
expect(screenPoint).toEqual({ x: 220, y: 140 })
|
|
})
|
|
|
|
it('should handle zero coordinates', () => {
|
|
const { canvasToScreen } = transformState
|
|
|
|
const screenPoint = canvasToScreen({ x: 0, y: 0 })
|
|
expect(screenPoint).toEqual({ x: 200, y: 100 })
|
|
})
|
|
|
|
it('should handle negative coordinates', () => {
|
|
const { canvasToScreen } = transformState
|
|
|
|
const screenPoint = canvasToScreen({ x: -10, y: -20 })
|
|
expect(screenPoint).toEqual({ x: 180, y: 60 })
|
|
})
|
|
})
|
|
|
|
describe('screenToCanvas', () => {
|
|
it('should convert screen coordinates to canvas coordinates', () => {
|
|
const { screenToCanvas } = transformState
|
|
|
|
const screenPoint = { x: 220, y: 140 }
|
|
const canvasPoint = screenToCanvas(screenPoint)
|
|
|
|
// canvas = screen / scale - offset
|
|
// x: 220 / 2 - 100 = 10
|
|
// y: 140 / 2 - 50 = 20
|
|
expect(canvasPoint).toEqual({ x: 10, y: 20 })
|
|
})
|
|
|
|
it('should be inverse of canvasToScreen', () => {
|
|
const { canvasToScreen, screenToCanvas } = transformState
|
|
|
|
const originalPoint = { x: 25, y: 35 }
|
|
const screenPoint = canvasToScreen(originalPoint)
|
|
const backToCanvas = screenToCanvas(screenPoint)
|
|
|
|
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
|
|
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getNodeScreenBounds', () => {
|
|
beforeEach(() => {
|
|
const mockCanvas = createMockCanvasContext()
|
|
mockCanvas.ds.offset = [100, 50]
|
|
mockCanvas.ds.scale = 2
|
|
transformState.syncWithCanvas(mockCanvas as any)
|
|
})
|
|
|
|
it('should calculate correct screen bounds for a node', () => {
|
|
const { getNodeScreenBounds } = transformState
|
|
|
|
const nodePos: [number, number] = [10, 20]
|
|
const nodeSize: [number, number] = [200, 100]
|
|
const bounds = getNodeScreenBounds(nodePos, nodeSize)
|
|
|
|
// Top-left: canvasToScreen(10, 20) = (220, 140)
|
|
// Width: 200 * 2 = 400
|
|
// Height: 100 * 2 = 200
|
|
expect(bounds.x).toBe(220)
|
|
expect(bounds.y).toBe(140)
|
|
expect(bounds.width).toBe(400)
|
|
expect(bounds.height).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('isNodeInViewport', () => {
|
|
beforeEach(() => {
|
|
const mockCanvas = createMockCanvasContext()
|
|
mockCanvas.ds.offset = [0, 0]
|
|
mockCanvas.ds.scale = 1
|
|
transformState.syncWithCanvas(mockCanvas as any)
|
|
})
|
|
|
|
const viewport = { width: 1000, height: 600 }
|
|
|
|
it('should return true for nodes inside viewport', () => {
|
|
const { isNodeInViewport } = transformState
|
|
|
|
const nodePos: [number, number] = [100, 100]
|
|
const nodeSize: [number, number] = [200, 100]
|
|
|
|
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
|
|
})
|
|
|
|
it('should return false for nodes completely outside viewport', () => {
|
|
const { isNodeInViewport } = transformState
|
|
|
|
// Node far to the right
|
|
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
|
|
|
|
// Node far to the left
|
|
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
|
|
|
|
// Node far below
|
|
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
|
|
|
|
// Node far above
|
|
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
|
|
})
|
|
|
|
it('should return true for nodes partially in viewport with margin', () => {
|
|
const { isNodeInViewport } = transformState
|
|
|
|
// Node slightly outside but within margin
|
|
const nodePos: [number, number] = [-50, -50]
|
|
const nodeSize: [number, number] = [100, 100]
|
|
|
|
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
|
|
})
|
|
|
|
it('should return false for tiny nodes (size culling)', () => {
|
|
const { isNodeInViewport } = transformState
|
|
|
|
// Node is in viewport but too small
|
|
const nodePos: [number, number] = [100, 100]
|
|
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
|
|
|
|
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
|
|
})
|
|
|
|
it('should adjust margin based on zoom level', () => {
|
|
const { isNodeInViewport, syncWithCanvas } = transformState
|
|
const mockCanvas = createMockCanvasContext()
|
|
|
|
// Test with very low zoom
|
|
mockCanvas.ds.scale = 0.05
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
// Node at edge should still be visible due to increased margin
|
|
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
|
|
|
|
// Test with high zoom
|
|
mockCanvas.ds.scale = 4
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
// Margin should be tighter
|
|
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getViewportBounds', () => {
|
|
beforeEach(() => {
|
|
const mockCanvas = createMockCanvasContext()
|
|
mockCanvas.ds.offset = [100, 50]
|
|
mockCanvas.ds.scale = 2
|
|
transformState.syncWithCanvas(mockCanvas as any)
|
|
})
|
|
|
|
it('should calculate viewport bounds in canvas coordinates', () => {
|
|
const { getViewportBounds } = transformState
|
|
const viewport = { width: 1000, height: 600 }
|
|
|
|
const bounds = getViewportBounds(viewport, 0.2)
|
|
|
|
// With 20% margin:
|
|
// marginX = 1000 * 0.2 = 200
|
|
// marginY = 600 * 0.2 = 120
|
|
// topLeft in screen: (-200, -120)
|
|
// bottomRight in screen: (1200, 720)
|
|
|
|
// Convert to canvas coordinates (canvas = screen / scale - offset):
|
|
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
|
|
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
|
|
|
|
expect(bounds.x).toBe(-200)
|
|
expect(bounds.y).toBe(-110)
|
|
expect(bounds.width).toBe(700) // 500 - (-200)
|
|
expect(bounds.height).toBe(420) // 310 - (-110)
|
|
})
|
|
|
|
it('should handle zero margin', () => {
|
|
const { getViewportBounds } = transformState
|
|
const viewport = { width: 1000, height: 600 }
|
|
|
|
const bounds = getViewportBounds(viewport, 0)
|
|
|
|
// No margin, so viewport bounds are exact
|
|
expect(bounds.x).toBe(-100) // 0 / 2 - 100
|
|
expect(bounds.y).toBe(-50) // 0 / 2 - 50
|
|
expect(bounds.width).toBe(500) // 1000 / 2
|
|
expect(bounds.height).toBe(300) // 600 / 2
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle extreme zoom levels', () => {
|
|
const { syncWithCanvas, canvasToScreen } = transformState
|
|
const mockCanvas = createMockCanvasContext()
|
|
|
|
// Very small zoom
|
|
mockCanvas.ds.scale = 0.001
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
const point1 = canvasToScreen({ x: 1000, y: 1000 })
|
|
expect(point1.x).toBeCloseTo(1)
|
|
expect(point1.y).toBeCloseTo(1)
|
|
|
|
// Very large zoom
|
|
mockCanvas.ds.scale = 100
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
const point2 = canvasToScreen({ x: 1, y: 1 })
|
|
expect(point2.x).toBe(100)
|
|
expect(point2.y).toBe(100)
|
|
})
|
|
|
|
it('should handle zero scale in screenToCanvas', () => {
|
|
const { syncWithCanvas, screenToCanvas } = transformState
|
|
const mockCanvas = createMockCanvasContext()
|
|
|
|
// Scale of 0 gets converted to 1 by || operator
|
|
mockCanvas.ds.scale = 0
|
|
syncWithCanvas(mockCanvas as any)
|
|
|
|
// Should use scale of 1 due to camera.z || 1 in implementation
|
|
const result = screenToCanvas({ x: 100, y: 100 })
|
|
expect(result.x).toBe(100) // (100 - 0) / 1
|
|
expect(result.y).toBe(100) // (100 - 0) / 1
|
|
})
|
|
})
|
|
})
|