5454 * }} NoFloRadialMenu
5555 */
5656
57+ /**
58+ * Duration (in ms) of the spring animation used when a dragged node jumps from
59+ * a blocked (colliding) position to a legal one. Kept short so the jump feels
60+ * snappy rather than sluggish.
61+ */
62+ const SPRING_MS = 180 ;
63+
64+ /**
65+ * CSS transition applied to nodes only while the spring jump is running. A
66+ * lightly overshooting cubic-bezier (a "back-out" curve) gives a springy feel
67+ * without lingering on the settle. It is removed again as soon as the
68+ * animation completes (see `_endSpring`).
69+ */
70+ const SPRING_TRANSITION = `left ${ SPRING_MS } ms cubic-bezier(0.34, 1.35, 0.64, 1), top ${ SPRING_MS } ms cubic-bezier(0.34, 1.35, 0.64, 1)` ;
71+
5772/**
5873 * FlowEditor Web Component
5974
@@ -116,26 +131,16 @@ export class FlowEditor extends HTMLElement {
116131 this . activityMap = new Map ( ) ;
117132 /** @type {Map<NoFloNode | NoFloIIP, Position> } */
118133 this . draggingNodesInitialPositions = new Map ( ) ;
119- /** @type {Map<NoFloNode | NoFloIIP, Position> } */
120- this . draggingNodesTargetPositions = new Map ( ) ;
121- /** @type {Map<NoFloNode | NoFloIIP, {x: number, y: number}> } */
122- this . nodeVelocities = new Map ( ) ;
123134 /** @type {Position | null } */
124135 this . draggingStartPointerPos = null ;
125- /** @type {number | null } */
126- this . animationFrameId = null ;
127136 /** @type {boolean } */
128137 this . isDraggingNodeInCollision = false ;
129- /** @type {Map<NoFloNode | NoFloIIP, Position> } */
130- this . draggingNodesInitialPositions = new Map ( ) ;
131- /** @type {Map<NoFloNode | NoFloIIP, Position> } */
132- this . draggingNodesTargetPositions = new Map ( ) ;
133- /** @type {Map<NoFloNode | NoFloIIP, {x: number, y: number}> } */
134- this . nodeVelocities = new Map ( ) ;
135- /** @type {Position | null } */
136- this . draggingStartPointerPos = null ;
138+ /** @type {boolean } */
139+ this . isSpringing = false ;
137140 /** @type {number | null } */
138- this . animationFrameId = null ;
141+ this . springRefreshFrame = null ;
142+ /** @type {number } */
143+ this . springEndTime = 0 ;
139144 /** @type {number | null } */
140145 this . panningPointerId = null ;
141146 /** @type {number | null } */
@@ -515,59 +520,96 @@ export class FlowEditor extends HTMLElement {
515520 this . style . setProperty ( "--zoom-scale" , this . zoom . toString ( ) ) ;
516521 }
517522
518- _animationLoop ( ) {
519- if ( this . animationFrameId === null ) {
520- this . animationFrameId = requestAnimationFrame ( ( ) =>
521- this . _animationLoop ( ) ,
522- ) ;
523- return ;
524- }
525-
526- const springStiffness = 0.15 ;
527- const springDamping = 0.8 ;
528-
529- let active = false ;
530-
531- this . draggingNodesTargetPositions . forEach ( ( targetPos , node ) => {
532- const currentPos = node . position ;
533- const velocity = this . nodeVelocities . get ( node ) || { x : 0 , y : 0 } ;
534-
535- const ax = ( targetPos . x - currentPos . x ) * springStiffness ;
536- const ay = ( targetPos . y - currentPos . y ) * springStiffness ;
537-
538- velocity . x = ( velocity . x + ax ) * springDamping ;
539- velocity . y = ( velocity . y + ay ) * springDamping ;
523+ /**
524+ * Whether the user has requested reduced motion. When true the spring jump
525+ * is skipped and nodes move instantly, per SPEC.md ("Disabling UI animations",
526+ * defaulting to the `prefers-reduced-motion` media query).
527+ *
528+ * @returns {boolean }
529+ */
530+ _prefersReducedMotion ( ) {
531+ return (
532+ typeof window !== "undefined" &&
533+ typeof window . matchMedia === "function" &&
534+ window . matchMedia ( "(prefers-reduced-motion: reduce)" ) . matches
535+ ) ;
536+ }
540537
541- const nextX = currentPos . x + velocity . x ;
542- const nextY = currentPos . y + velocity . y ;
538+ /**
539+ * Starts a one-shot spring CSS animation that moves the given nodes to their
540+ * target positions. Used when a dragged node was blocked by a collision and
541+ * the pointer has since moved to a legal spot: instead of teleporting there
542+ * instantly, the node springs to its new position. The CSS transition is
543+ * removed again as soon as the animation completes (see `_endSpring`), so
544+ * subsequent pointer movement follows the pointer instantly.
545+ *
546+ * While the spring plays the nodes are frozen: pointer movement is ignored
547+ * so the animation can run uninterrupted to its target.
548+ *
549+ * @param {Array<{node: NoFloNode | NoFloIIP, x: number, y: number}> } moves
550+ */
551+ _startSpringJump ( moves ) {
552+ if ( moves . length === 0 ) return ;
543553
544- if (
545- Math . abs ( nextX - currentPos . x ) > 0.01 ||
546- Math . abs ( nextY - currentPos . y ) > 0.01
547- ) {
548- node . position = { x : nextX , y : nextY } ;
549- this . nodeVelocities . set ( node , velocity ) ;
550- active = true ;
551- } else {
552- node . position = { x : targetPos . x , y : targetPos . y } ;
553- this . nodeVelocities . set ( node , { x : 0 , y : 0 } ) ;
554+ if ( this . _prefersReducedMotion ( ) ) {
555+ // No animation: jump straight to the target and keep following instantly.
556+ for ( const { node, x, y } of moves ) {
557+ node . position = { x, y } ;
554558 }
555- } ) ;
556-
557- if ( active ) {
558559 this . updateEdges ( ) ;
559560 this . updateIIPWires ( ) ;
560- this . animationFrameId = requestAnimationFrame ( ( ) =>
561- this . _animationLoop ( ) ,
561+ return ;
562+ }
563+
564+ this . isSpringing = true ;
565+ for ( const { node, x, y } of moves ) {
566+ // Apply the transition before changing the position so the browser
567+ // animates from the current (stuck) position to the new target.
568+ node . style . transition = SPRING_TRANSITION ;
569+ node . position = { x, y } ;
570+ }
571+ this . springEndTime = performance . now ( ) + SPRING_MS ;
572+ this . _springRefreshLoop ( ) ;
573+ }
574+
575+ /**
576+ * Animation-frame loop that keeps edges and IIP wires attached to nodes while
577+ * they are mid-spring. Because of the CSS transition the nodes' visual
578+ * position lags behind their logical `position`, so we re-read the live port
579+ * positions every frame. Ends the spring (removing the transition) once the
580+ * configured duration has elapsed.
581+ */
582+ _springRefreshLoop ( ) {
583+ this . updateEdges ( ) ;
584+ this . updateIIPWires ( ) ;
585+ if ( performance . now ( ) < this . springEndTime ) {
586+ this . springRefreshFrame = window . requestAnimationFrame ( ( ) =>
587+ this . _springRefreshLoop ( ) ,
562588 ) ;
563589 } else {
564- if ( this . isDraggingNodeInCollision ) {
565- this . isDraggingNodeInCollision = false ;
566- }
567- this . animationFrameId = null ;
590+ this . _endSpring ( ) ;
568591 }
569592 }
570593
594+ /**
595+ * Ends the spring animation, removing the CSS transition from the dragged
596+ * nodes so that further pointer movement follows the pointer instantly again.
597+ * Safe to call when no spring is currently running.
598+ */
599+ _endSpring ( ) {
600+ this . isSpringing = false ;
601+ if ( this . springRefreshFrame !== null ) {
602+ window . cancelAnimationFrame ( this . springRefreshFrame ) ;
603+ this . springRefreshFrame = null ;
604+ }
605+ if ( this . selectedNodes ) {
606+ this . selectedNodes . forEach ( ( node ) => {
607+ node . style . transition = "" ;
608+ } ) ;
609+ }
610+ this . springEndTime = 0 ;
611+ }
612+
571613 /**
572614 * @param {number } clientX
573615 * @param {number } clientY
@@ -846,9 +888,6 @@ export class FlowEditor extends HTMLElement {
846888 clearTimeout ( this . longPressTimer ) ;
847889 this . longPressTimer = null ;
848890 }
849- if ( this . animationFrameId === null ) {
850- this . _animationLoop ( ) ;
851- }
852891 }
853892 }
854893
@@ -893,30 +932,35 @@ export class FlowEditor extends HTMLElement {
893932 }
894933
895934 if ( ! collision ) {
896- idealMoves . forEach ( ( { node , x , y } ) => {
897- this . draggingNodesTargetPositions . set ( node , { x , y } ) ;
898- } ) ;
899-
900- if ( this . isDraggingNodeInCollision ) {
901- // We were in collision, and now we are not!
902- // We want to animate to the new position .
903- // So we DO NOT update node.position directly.
904- // We ensure animation loop is running
905- if ( this . animationFrameId === null ) {
906- this . _animationLoop ( ) ;
935+ if ( this . isSpringing ) {
936+ // A spring jump is playing; the nodes are frozen mid-jump so
937+ // we intentionally ignore pointer movement until it settles.
938+ } else if ( this . isDraggingNodeInCollision ) {
939+ // Pointer moved from an illegal spot to a legal one. Instead
940+ // of teleporting, spring the nodes to their new positions with
941+ // a one-shot CSS animation .
942+ this . isDraggingNodeInCollision = false ;
943+ this . _startSpringJump ( idealMoves ) ;
944+ if ( this . viewport ) {
945+ this . viewport . style . cursor = "" ;
907946 }
908947 } else {
909- // Normal drag, no collision.
910- // We want no animation, so update node.position directly .
948+ // Normal drag, no collision and no spring: follow the pointer
949+ // instantly without any animation.
911950 idealMoves . forEach ( ( { node, x, y } ) => {
912951 node . position = { x, y } ;
913- this . nodeVelocities . set ( node , { x : 0 , y : 0 } ) ;
914952 } ) ;
953+ this . updateEdges ( ) ;
954+ this . updateIIPWires ( ) ;
915955 }
916- this . updateEdges ( ) ;
917- this . updateIIPWires ( ) ;
918956 } else {
919957 this . isDraggingNodeInCollision = true ;
958+ if ( this . isSpringing ) {
959+ // Pointer re-entered an illegal area mid-spring; cancel the
960+ // spring so the node sticks where it is instead of continuing
961+ // into the collision.
962+ this . _endSpring ( ) ;
963+ }
920964 if ( this . viewport ) {
921965 this . viewport . style . cursor = "no-drop" ;
922966 }
@@ -975,6 +1019,8 @@ export class FlowEditor extends HTMLElement {
9751019 }
9761020
9771021 if ( this . isDraggingNode ) {
1022+ // Stop any in-flight spring so the snap-to-grid below is instant.
1023+ this . _endSpring ( ) ;
9781024 /** @type {Array<{name: string, position: Position}> } */
9791025 const movedNodes = [ ] ;
9801026 this . selectedNodes . forEach ( ( node ) => {
@@ -1001,8 +1047,6 @@ export class FlowEditor extends HTMLElement {
10011047 // Cleanup drag state
10021048 this . draggingStartPointerPos = null ;
10031049 this . draggingNodesInitialPositions . clear ( ) ;
1004- this . draggingNodesTargetPositions . clear ( ) ;
1005- this . nodeVelocities . clear ( ) ;
10061050 this . isDraggingNodeInCollision = false ;
10071051 }
10081052 if ( e . pointerId === this . draggingWirePointerId ) {
@@ -1486,13 +1530,11 @@ export class FlowEditor extends HTMLElement {
14861530
14871531 // Prepare for potential drag
14881532 this . isDraggingNodeInCollision = false ;
1533+ this . isSpringing = false ;
14891534 this . draggingStartPointerPos = { x : e . clientX , y : e . clientY } ;
14901535 this . draggingNodesInitialPositions . clear ( ) ;
1491- this . draggingNodesTargetPositions . clear ( ) ;
1492- this . nodeVelocities . clear ( ) ;
14931536 this . selectedNodes . forEach ( ( node ) => {
14941537 this . draggingNodesInitialPositions . set ( node , { ...node . position } ) ;
1495- this . draggingNodesTargetPositions . set ( node , { ...node . position } ) ;
14961538 } ) ;
14971539
14981540 this . emitSelectionChanged ( ) ;
0 commit comments