Skip to content

Commit 83b6a64

Browse files
committed
Simplify spring animation
1 parent 22d13d6 commit 83b6a64

2 files changed

Lines changed: 222 additions & 82 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import assert from "node:assert";
2+
import { describe, it } from "node:test";
3+
4+
import "./utils/register.js";
5+
6+
import { FlowEditor } from "../../src/elements/noflo-editor.js";
7+
import { FlowNode } from "../../src/elements/noflo-node.js";
8+
9+
customElements.define("noflo-editor", FlowEditor);
10+
customElements.define("noflo-node", FlowNode);
11+
12+
/**
13+
* @param {FlowEditor} el
14+
* @returns {FlowNode}
15+
*/
16+
function makeSelectedNode(el) {
17+
const node = /** @type {FlowNode} */ (
18+
document.createElement("noflo-node")
19+
);
20+
node.size = 80;
21+
node.position = { x: 0, y: 0 };
22+
el.nodeLayer.appendChild(node);
23+
el.selectedNodes.add(node);
24+
return node;
25+
}
26+
27+
describe("FlowEditor spring jump", () => {
28+
it("applies a spring CSS transition when a node jumps to a legal spot", () => {
29+
const el = /** @type {FlowEditor} */ (
30+
document.createElement("noflo-editor")
31+
);
32+
document.body.appendChild(el);
33+
34+
const node = makeSelectedNode(el);
35+
36+
el._startSpringJump([{ node, x: 120, y: 80 }]);
37+
38+
// The spring should be active and a springy transition applied...
39+
assert.strictEqual(el.isSpringing, true);
40+
assert.ok(
41+
node.style.transition.includes("cubic-bezier"),
42+
`expected a cubic-bezier transition, got: ${node.style.transition}`,
43+
);
44+
// ...and the node should already be at its target logical position.
45+
assert.strictEqual(node.position.x, 120);
46+
assert.strictEqual(node.position.y, 80);
47+
48+
el._endSpring();
49+
document.body.removeChild(el);
50+
});
51+
52+
it("removes the transition as soon as the spring completes", () => {
53+
const el = /** @type {FlowEditor} */ (
54+
document.createElement("noflo-editor")
55+
);
56+
document.body.appendChild(el);
57+
58+
const node = makeSelectedNode(el);
59+
60+
el._startSpringJump([{ node, x: 200, y: 200 }]);
61+
assert.ok(node.style.transition.includes("cubic-bezier"));
62+
63+
el._endSpring();
64+
65+
// The transition is removed so subsequent movement is instant again.
66+
assert.strictEqual(el.isSpringing, false);
67+
assert.strictEqual(node.style.transition, "");
68+
assert.strictEqual(el.springRefreshFrame, null);
69+
70+
document.body.removeChild(el);
71+
});
72+
73+
it("skips the animation when reduced motion is requested", () => {
74+
const el = /** @type {FlowEditor} */ (
75+
document.createElement("noflo-editor")
76+
);
77+
document.body.appendChild(el);
78+
79+
const node = makeSelectedNode(el);
80+
81+
const originalMatchMedia = window.matchMedia;
82+
/** @type {any} */
83+
window.matchMedia = () => ({ matches: true });
84+
try {
85+
el._startSpringJump([{ node, x: 50, y: 50 }]);
86+
} finally {
87+
window.matchMedia = originalMatchMedia;
88+
}
89+
90+
// No spring, no transition, but the node still jumps to the target.
91+
assert.strictEqual(el.isSpringing, false);
92+
assert.strictEqual(node.style.transition, "");
93+
assert.strictEqual(node.position.x, 50);
94+
assert.strictEqual(node.position.y, 50);
95+
96+
document.body.removeChild(el);
97+
});
98+
});

src/elements/noflo-editor.js

Lines changed: 124 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@
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

Comments
 (0)