|
| 1 | +/** |
| 2 | + * Single-Node Fly-In Animation |
| 3 | + * |
| 4 | + * Animates a newly placed node flying in from the left edge of the viewport. |
| 5 | + * Reuses the CSS animation infrastructure from assemblyAnimation. |
| 6 | + * |
| 7 | + * Usage: |
| 8 | + * import { runFlyInAnimation } from '$lib/animation/flyInAnimation'; |
| 9 | + * |
| 10 | + * // Inside SvelteFlow context (FlowUpdater): |
| 11 | + * runFlyInAnimation(nodeId, position, getViewport); |
| 12 | + */ |
| 13 | + |
| 14 | +// ============================================================================ |
| 15 | +// Configuration |
| 16 | +// ============================================================================ |
| 17 | + |
| 18 | +const CONFIG = { |
| 19 | + duration: 300, // Animation duration (ms) |
| 20 | + flyDistanceMargin: 100, // Extra margin beyond viewport edge (px in flow coords) |
| 21 | + domReadyDelay: 50 // Wait for DOM to render after node creation (ms) |
| 22 | +}; |
| 23 | + |
| 24 | +// ============================================================================ |
| 25 | +// Types |
| 26 | +// ============================================================================ |
| 27 | + |
| 28 | +export interface ViewportInfo { |
| 29 | + zoom: number; |
| 30 | + x: number; // Viewport pan x |
| 31 | + y: number; // Viewport pan y |
| 32 | + width: number; // Canvas width in pixels |
| 33 | + height: number; // Canvas height in pixels |
| 34 | +} |
| 35 | + |
| 36 | +// ============================================================================ |
| 37 | +// Animation Function |
| 38 | +// ============================================================================ |
| 39 | + |
| 40 | +/** |
| 41 | + * Animate a single node flying in from the cursor position |
| 42 | + * |
| 43 | + * @param nodeId - The ID of the node to animate |
| 44 | + * @param targetPosition - The node's final position in flow coordinates |
| 45 | + * @param getViewport - Function to get current viewport info |
| 46 | + * @param cursorScreen - Cursor position in screen coordinates (optional) |
| 47 | + * @param screenToFlow - Function to convert screen to flow coordinates (optional) |
| 48 | + */ |
| 49 | +export function runFlyInAnimation( |
| 50 | + nodeId: string, |
| 51 | + targetPosition: { x: number; y: number }, |
| 52 | + getViewport: () => ViewportInfo, |
| 53 | + cursorScreen?: { x: number; y: number } | null, |
| 54 | + screenToFlow?: (pos: { x: number; y: number }) => { x: number; y: number } |
| 55 | +): void { |
| 56 | + let flyFromX: number; |
| 57 | + let flyFromY: number; |
| 58 | + |
| 59 | + if (cursorScreen && screenToFlow) { |
| 60 | + // Convert cursor screen position to flow coordinates |
| 61 | + const cursorFlow = screenToFlow(cursorScreen); |
| 62 | + // Calculate offset from target position (fly-from is relative to final position) |
| 63 | + flyFromX = cursorFlow.x - targetPosition.x; |
| 64 | + flyFromY = cursorFlow.y - targetPosition.y; |
| 65 | + } else { |
| 66 | + // Fallback: fly from left edge of viewport |
| 67 | + const viewport = getViewport(); |
| 68 | + const leftEdgeX = -viewport.x / viewport.zoom; |
| 69 | + flyFromX = leftEdgeX - CONFIG.flyDistanceMargin - targetPosition.x; |
| 70 | + flyFromY = 0; |
| 71 | + } |
| 72 | + |
| 73 | + // Poll for DOM element (typically appears within 1-2 frames) |
| 74 | + let attempts = 0; |
| 75 | + const maxAttempts = 20; |
| 76 | + |
| 77 | + function tryAnimate() { |
| 78 | + const nodeEl = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; |
| 79 | + |
| 80 | + if (!nodeEl) { |
| 81 | + attempts++; |
| 82 | + if (attempts < maxAttempts) { |
| 83 | + requestAnimationFrame(tryAnimate); |
| 84 | + } |
| 85 | + return; |
| 86 | + } |
| 87 | + |
| 88 | + // Immediately set initial position to prevent flicker |
| 89 | + // This positions the node at the start of the animation before CSS takes over |
| 90 | + nodeEl.style.translate = `${flyFromX}px ${flyFromY}px`; |
| 91 | + nodeEl.style.scale = '0.8'; |
| 92 | + nodeEl.style.opacity = '0'; |
| 93 | + |
| 94 | + // Set CSS variables for the animation |
| 95 | + nodeEl.style.setProperty('--fly-from-x', `${flyFromX}px`); |
| 96 | + nodeEl.style.setProperty('--fly-from-y', `${flyFromY}px`); |
| 97 | + nodeEl.style.setProperty('--assembly-duration', `${CONFIG.duration}ms`); |
| 98 | + nodeEl.style.setProperty('--assembly-delay', '0ms'); |
| 99 | + |
| 100 | + // Start animation in next frame (after initial styles are applied) |
| 101 | + requestAnimationFrame(() => { |
| 102 | + // Remove inline styles and let animation take over |
| 103 | + nodeEl.style.removeProperty('translate'); |
| 104 | + nodeEl.style.removeProperty('scale'); |
| 105 | + nodeEl.style.removeProperty('opacity'); |
| 106 | + |
| 107 | + // Add the assembling class to trigger the CSS animation |
| 108 | + nodeEl.classList.add('assembling'); |
| 109 | + |
| 110 | + // Cleanup after animation completes |
| 111 | + setTimeout(() => { |
| 112 | + nodeEl.classList.remove('assembling'); |
| 113 | + nodeEl.style.removeProperty('--fly-from-x'); |
| 114 | + nodeEl.style.removeProperty('--fly-from-y'); |
| 115 | + nodeEl.style.removeProperty('--assembly-duration'); |
| 116 | + nodeEl.style.removeProperty('--assembly-delay'); |
| 117 | + }, CONFIG.duration + 50); |
| 118 | + }); |
| 119 | + } |
| 120 | + |
| 121 | + requestAnimationFrame(tryAnimate); |
| 122 | +} |
0 commit comments