Skip to content

Commit c7c13bf

Browse files
authored
Merge pull request #247 from pathsim/ui-improvements
UX improvements for node placement
2 parents bc141ad + 4682075 commit c7c13bf

6 files changed

Lines changed: 234 additions & 8 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
}

src/lib/components/FlowUpdater.svelte

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import { historyStore } from '$lib/stores/history';
77
import { eventRegistry } from '$lib/events/registry';
88
import type { EventInstance } from '$lib/events/types';
9-
import { fitViewTrigger, fitViewPadding, type FitViewPadding, zoomInTrigger, zoomOutTrigger, panTrigger, focusNodeTrigger, registerScreenToFlowConverter } from '$lib/stores/viewActions';
9+
import { fitViewTrigger, fitViewPadding, type FitViewPadding, zoomInTrigger, zoomOutTrigger, panTrigger, focusNodeTrigger, registerScreenToFlowConverter, flyInAnimationTrigger } from '$lib/stores/viewActions';
1010
import { get } from 'svelte/store';
1111
import { dropTargetBridge } from '$lib/stores/dropTargetBridge';
1212
import { assemblyAnimationTrigger, runAssemblyAnimation } from '$lib/animation/assemblyAnimation';
13+
import { runFlyInAnimation } from '$lib/animation/flyInAnimation';
1314
import { importFile } from '$lib/schema/fileOps';
1415
import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component';
16+
import { GRID_SIZE } from '$lib/constants/grid';
1517
1618
interface Props {
1719
pendingUpdates: string[];
@@ -131,12 +133,14 @@
131133
// Check for node drop
132134
const nodeType = event.dataTransfer?.getData('application/pathview-node');
133135
if (nodeType) {
136+
// Snap cursor position to grid - node center will be at cursor
137+
// (nodes use center origin [0.5, 0.5])
138+
const snappedX = Math.round(position.x / GRID_SIZE) * GRID_SIZE;
139+
const snappedY = Math.round(position.y / GRID_SIZE) * GRID_SIZE;
140+
134141
// addNode uses current navigation context automatically
135142
historyStore.mutate(() => {
136-
graphStore.addNode(nodeType, {
137-
x: position.x - 80,
138-
y: position.y - 30
139-
});
143+
graphStore.addNode(nodeType, { x: snappedX, y: snappedY });
140144
});
141145
return;
142146
}
@@ -293,4 +297,29 @@
293297
);
294298
}
295299
});
300+
301+
// Listen for fly-in animation trigger (when node is added via click)
302+
let lastFlyInTrigger = 0;
303+
flyInAnimationTrigger.subscribe((value) => {
304+
if (value.id > lastFlyInTrigger && value.nodeId) {
305+
lastFlyInTrigger = value.id;
306+
runFlyInAnimation(
307+
value.nodeId,
308+
value.position,
309+
() => {
310+
const vp = getViewport();
311+
const canvas = document.querySelector('.svelte-flow') as HTMLElement;
312+
return {
313+
zoom: vp.zoom,
314+
x: vp.x,
315+
y: vp.y,
316+
width: canvas?.clientWidth ?? window.innerWidth,
317+
height: canvas?.clientHeight ?? window.innerHeight
318+
};
319+
},
320+
value.cursorScreen,
321+
screenToFlowPosition
322+
);
323+
}
324+
});
296325
</script>

src/lib/components/panels/NodeLibrary.svelte

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
// Track drag state to prevent click after drag
2323
let isDragging = $state(false);
2424
25+
// Drag preview - rendered off-screen, used as drag image
26+
let dragPreviewNode = $state<NodeTypeDefinition | null>(null);
27+
let dragPreviewElement: HTMLDivElement;
28+
2529
// Collapsed categories
2630
let collapsedCategories = $state<Set<string>>(new Set());
2731
@@ -85,12 +89,27 @@
8589
return result;
8690
});
8791
92+
// Handle mouse enter to prepare drag preview
93+
function handleMouseEnter(node: NodeTypeDefinition) {
94+
dragPreviewNode = node;
95+
}
96+
8897
// Handle drag start
8998
function handleDragStart(event: DragEvent, nodeType: NodeTypeDefinition) {
9099
isDragging = true;
91100
if (event.dataTransfer) {
92101
event.dataTransfer.setData('application/pathview-node', nodeType.type);
93102
event.dataTransfer.effectAllowed = 'copy';
103+
104+
// Use the pre-rendered preview as drag image, centered on cursor
105+
if (dragPreviewElement) {
106+
const rect = dragPreviewElement.getBoundingClientRect();
107+
event.dataTransfer.setDragImage(
108+
dragPreviewElement,
109+
rect.width / 2,
110+
rect.height / 2
111+
);
112+
}
94113
}
95114
}
96115
@@ -99,6 +118,7 @@
99118
// Reset after a short delay to prevent click from firing
100119
setTimeout(() => {
101120
isDragging = false;
121+
dragPreviewNode = null;
102122
}, 100);
103123
}
104124
@@ -196,6 +216,7 @@
196216
class="node-tile"
197217
class:selected={isSelected(node)}
198218
draggable="true"
219+
onmouseenter={() => handleMouseEnter(node)}
199220
ondragstart={(e) => handleDragStart(e, node)}
200221
ondragend={handleDragEnd}
201222
onclick={() => handleNodeClick(node)}
@@ -219,6 +240,15 @@
219240
<span>Click or drag to add</span>
220241
<span>↑↓ Enter</span>
221242
</div>
243+
244+
<!-- Hidden drag preview container (rendered off-screen, used as drag image) -->
245+
<div class="drag-preview-container" aria-hidden="true">
246+
{#if dragPreviewNode}
247+
<div bind:this={dragPreviewElement} class="drag-preview-wrapper">
248+
<NodePreview node={dragPreviewNode} />
249+
</div>
250+
{/if}
251+
</div>
222252
</div>
223253

224254
<style>
@@ -380,4 +410,16 @@
380410
font-size: 10px;
381411
color: var(--text-disabled);
382412
}
413+
414+
/* Hidden container for drag preview image */
415+
.drag-preview-container {
416+
position: fixed;
417+
left: -9999px;
418+
top: -9999px;
419+
pointer-events: none;
420+
}
421+
422+
.drag-preview-wrapper {
423+
display: inline-block;
424+
}
383425
</style>

src/lib/stores/viewActions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export {
2727
selectNodeTrigger,
2828
triggerSelectNodes,
2929
editAnnotationTrigger,
30-
triggerEditAnnotation
30+
triggerEditAnnotation,
31+
flyInAnimationTrigger,
32+
triggerFlyInAnimation
3133
} from './viewTriggers';
3234

3335
// Re-export all utilities

src/lib/stores/viewTriggers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,29 @@ export const editAnnotationTrigger = writable<{ annotationId: string; id: number
9292
export function triggerEditAnnotation(annotationId: string): void {
9393
editAnnotationTrigger.update((current) => ({ annotationId, id: current.id + 1 }));
9494
}
95+
96+
// Fly-in animation trigger - triggers fly-in animation for a newly placed node
97+
export const flyInAnimationTrigger = writable<{
98+
nodeId: string;
99+
position: { x: number; y: number };
100+
cursorScreen: { x: number; y: number } | null;
101+
id: number
102+
}>({
103+
nodeId: '',
104+
position: { x: 0, y: 0 },
105+
cursorScreen: null,
106+
id: 0
107+
});
108+
109+
export function triggerFlyInAnimation(
110+
nodeId: string,
111+
position: { x: number; y: number },
112+
cursorScreen?: { x: number; y: number }
113+
): void {
114+
flyInAnimationTrigger.update((current) => ({
115+
nodeId,
116+
position,
117+
cursorScreen: cursorScreen ?? null,
118+
id: current.id + 1
119+
}));
120+
}

src/routes/+page.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps';
4646
import { confirmationStore } from '$lib/stores/confirmation';
4747
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
48-
import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding } from '$lib/stores/viewActions';
48+
import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions';
4949
import { nodeUpdatesStore } from '$lib/stores/nodeUpdates';
5050
import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews';
5151
import { clipboardStore } from '$lib/stores/clipboard';
@@ -933,7 +933,12 @@
933933
934934
// addNode uses current navigation context automatically
935935
// Subsystem creation auto-creates Interface block inside
936-
historyStore.mutate(() => graphStore.addNode(type, position));
936+
const newNode = historyStore.mutate(() => graphStore.addNode(type, position));
937+
938+
// Trigger fly-in animation for the new node (from cursor position)
939+
if (newNode) {
940+
triggerFlyInAnimation(newNode.id, position, mousePosition);
941+
}
937942
}
938943
</script>
939944

0 commit comments

Comments
 (0)