@lab206/animate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +114 -0
- package/README.md +6 -0
- package/dist/animate.d.ts +28 -0
- package/dist/animate.d.ts.map +1 -0
- package/dist/animate.js +184 -0
- package/dist/animate.js.map +1 -0
- package/dist/driver.d.ts +24 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +87 -0
- package/dist/driver.js.map +1 -0
- package/dist/easings.d.ts +6 -0
- package/dist/easings.d.ts.map +1 -0
- package/dist/easings.js +36 -0
- package/dist/easings.js.map +1 -0
- package/dist/gsap-bridge.d.ts +65 -0
- package/dist/gsap-bridge.d.ts.map +1 -0
- package/dist/gsap-bridge.js +98 -0
- package/dist/gsap-bridge.js.map +1 -0
- package/dist/gsap.d.ts +8 -0
- package/dist/gsap.d.ts.map +1 -0
- package/dist/gsap.js +21 -0
- package/dist/gsap.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/playback.d.ts +10 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +58 -0
- package/dist/playback.js.map +1 -0
- package/dist/reduced-motion.d.ts +11 -0
- package/dist/reduced-motion.d.ts.map +1 -0
- package/dist/reduced-motion.js +25 -0
- package/dist/reduced-motion.js.map +1 -0
- package/dist/registry.d.ts +8 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +19 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +73 -0
- package/src/animate.ts +256 -0
- package/src/driver.ts +100 -0
- package/src/easings.ts +41 -0
- package/src/gsap-bridge.ts +183 -0
- package/src/gsap.ts +30 -0
- package/src/index.ts +45 -0
- package/src/playback.ts +67 -0
- package/src/reduced-motion.ts +23 -0
- package/src/registry.ts +31 -0
- package/src/types.ts +88 -0
package/src/animate.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { Signal } from "@lab206/core";
|
|
2
|
+
import { driver } from "./driver.js";
|
|
3
|
+
import { resolveEase } from "./easings.js";
|
|
4
|
+
import { createPlayback } from "./playback.js";
|
|
5
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
6
|
+
import { clearActive, getActive, setActive } from "./registry.js";
|
|
7
|
+
import type {
|
|
8
|
+
AnimateOptions,
|
|
9
|
+
AnimationPlayback,
|
|
10
|
+
SpringOptions,
|
|
11
|
+
TweenOptions,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DURATION = 300;
|
|
15
|
+
const DEFAULT_STIFFNESS = 170;
|
|
16
|
+
const DEFAULT_DAMPING = 26;
|
|
17
|
+
const DEFAULT_MASS = 1;
|
|
18
|
+
const DEFAULT_REST_DELTA = 0.1;
|
|
19
|
+
const DEFAULT_REST_SPEED = 0.01;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create spring options for `animate()`.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* animate(x, 100, spring({ stiffness: 200, damping: 20 }));
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function spring(
|
|
30
|
+
options: Omit<SpringOptions, "type"> = {},
|
|
31
|
+
): SpringOptions {
|
|
32
|
+
return { type: "spring", ...options };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Animate a number `signal` to a target value.
|
|
37
|
+
*
|
|
38
|
+
* - **Tween (default):** duration + easing
|
|
39
|
+
* - **Spring:** pass `spring({ ... })` or `{ type: "spring", ... }`
|
|
40
|
+
* - **Interruptible:** calling `animate` again on the same signal cancels the prior run
|
|
41
|
+
* - **A11y:** honors `prefers-reduced-motion` (jumps to end) unless opted out
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const x = signal(0);
|
|
46
|
+
* const anim = animate(x, 100, { duration: 300, ease: "easeOut" });
|
|
47
|
+
* await anim.finished;
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function animate(
|
|
51
|
+
target: Signal<number>,
|
|
52
|
+
to: number,
|
|
53
|
+
options: AnimateOptions = {},
|
|
54
|
+
): AnimationPlayback {
|
|
55
|
+
// Interrupt any prior animation on this signal.
|
|
56
|
+
getActive(target)?.cancel();
|
|
57
|
+
|
|
58
|
+
const isSpring = options.type === "spring";
|
|
59
|
+
const respect =
|
|
60
|
+
options.respectReducedMotion !== false && prefersReducedMotion();
|
|
61
|
+
const from =
|
|
62
|
+
options.from !== undefined ? options.from : target.peek();
|
|
63
|
+
|
|
64
|
+
let frameId: number | null = null;
|
|
65
|
+
|
|
66
|
+
const stopFrames = () => {
|
|
67
|
+
if (frameId !== null) {
|
|
68
|
+
driver.caf(frameId);
|
|
69
|
+
frameId = null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const apply = (value: number) => {
|
|
74
|
+
target.set(value);
|
|
75
|
+
options.onUpdate?.(value);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let finishedCleanup = false;
|
|
79
|
+
const finishClean = () => {
|
|
80
|
+
if (finishedCleanup) return;
|
|
81
|
+
finishedCleanup = true;
|
|
82
|
+
stopFrames();
|
|
83
|
+
clearActive(target, playback);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const playback = createPlayback({
|
|
87
|
+
onCancel: () => {
|
|
88
|
+
finishClean();
|
|
89
|
+
options.onCancel?.();
|
|
90
|
+
},
|
|
91
|
+
onComplete: () => {
|
|
92
|
+
apply(to);
|
|
93
|
+
finishClean();
|
|
94
|
+
options.onComplete?.();
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
setActive(target, playback);
|
|
99
|
+
|
|
100
|
+
// Already there, or reduced motion → snap finish.
|
|
101
|
+
if (respect || Object.is(from, to)) {
|
|
102
|
+
apply(to);
|
|
103
|
+
finishClean();
|
|
104
|
+
options.onComplete?.();
|
|
105
|
+
playback._resolveFinished();
|
|
106
|
+
return playback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const delay = options.delay ?? 0;
|
|
110
|
+
const startAt = driver.now() + delay;
|
|
111
|
+
|
|
112
|
+
const schedule = (cb: () => void) => {
|
|
113
|
+
frameId = driver.raf(() => {
|
|
114
|
+
frameId = null;
|
|
115
|
+
if (playback.playState !== "running") return;
|
|
116
|
+
cb();
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const begin = () => {
|
|
121
|
+
if (playback.playState !== "running") return;
|
|
122
|
+
if (isSpring) {
|
|
123
|
+
startSpring(
|
|
124
|
+
from,
|
|
125
|
+
to,
|
|
126
|
+
options as SpringOptions,
|
|
127
|
+
playback,
|
|
128
|
+
apply,
|
|
129
|
+
schedule,
|
|
130
|
+
() => {
|
|
131
|
+
finishClean();
|
|
132
|
+
options.onComplete?.();
|
|
133
|
+
playback._resolveFinished();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
startTween(
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
options as TweenOptions,
|
|
141
|
+
playback,
|
|
142
|
+
apply,
|
|
143
|
+
schedule,
|
|
144
|
+
() => {
|
|
145
|
+
finishClean();
|
|
146
|
+
options.onComplete?.();
|
|
147
|
+
playback._resolveFinished();
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (delay > 0) {
|
|
154
|
+
const wait = () => {
|
|
155
|
+
if (playback.playState !== "running") return;
|
|
156
|
+
if (driver.now() < startAt) {
|
|
157
|
+
schedule(wait);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
begin();
|
|
161
|
+
};
|
|
162
|
+
schedule(wait);
|
|
163
|
+
} else {
|
|
164
|
+
begin();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return playback;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function startTween(
|
|
171
|
+
from: number,
|
|
172
|
+
to: number,
|
|
173
|
+
options: TweenOptions,
|
|
174
|
+
playback: AnimationPlayback,
|
|
175
|
+
apply: (v: number) => void,
|
|
176
|
+
schedule: (cb: () => void) => void,
|
|
177
|
+
onDone: () => void,
|
|
178
|
+
): void {
|
|
179
|
+
const duration = Math.max(0, options.duration ?? DEFAULT_DURATION);
|
|
180
|
+
const ease = resolveEase(options.ease ?? "easeOut");
|
|
181
|
+
const start = driver.now();
|
|
182
|
+
|
|
183
|
+
if (duration === 0) {
|
|
184
|
+
apply(to);
|
|
185
|
+
onDone();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const tick = () => {
|
|
190
|
+
if (playback.playState !== "running") return;
|
|
191
|
+
|
|
192
|
+
const elapsed = driver.now() - start;
|
|
193
|
+
const t = Math.min(1, elapsed / duration);
|
|
194
|
+
apply(from + (to - from) * ease(t));
|
|
195
|
+
|
|
196
|
+
if (t >= 1) {
|
|
197
|
+
apply(to);
|
|
198
|
+
onDone();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
schedule(tick);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
schedule(tick);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function startSpring(
|
|
208
|
+
from: number,
|
|
209
|
+
to: number,
|
|
210
|
+
options: SpringOptions,
|
|
211
|
+
playback: AnimationPlayback,
|
|
212
|
+
apply: (v: number) => void,
|
|
213
|
+
schedule: (cb: () => void) => void,
|
|
214
|
+
onDone: () => void,
|
|
215
|
+
): void {
|
|
216
|
+
const stiffness = options.stiffness ?? DEFAULT_STIFFNESS;
|
|
217
|
+
const damping = options.damping ?? DEFAULT_DAMPING;
|
|
218
|
+
const mass = options.mass ?? DEFAULT_MASS;
|
|
219
|
+
const restDelta = options.restDelta ?? DEFAULT_REST_DELTA;
|
|
220
|
+
const restSpeed = options.restSpeed ?? DEFAULT_REST_SPEED;
|
|
221
|
+
|
|
222
|
+
let current = from;
|
|
223
|
+
let velocity = options.velocity ?? 0;
|
|
224
|
+
let last = driver.now();
|
|
225
|
+
|
|
226
|
+
apply(current);
|
|
227
|
+
|
|
228
|
+
const tick = () => {
|
|
229
|
+
if (playback.playState !== "running") return;
|
|
230
|
+
|
|
231
|
+
const now = driver.now();
|
|
232
|
+
const dt = Math.min(0.064, Math.max(0.001, (now - last) / 1000));
|
|
233
|
+
last = now;
|
|
234
|
+
|
|
235
|
+
const springForce = -stiffness * (current - to);
|
|
236
|
+
const damperForce = -damping * velocity;
|
|
237
|
+
const acceleration = (springForce + damperForce) / mass;
|
|
238
|
+
velocity += acceleration * dt;
|
|
239
|
+
current += velocity * dt;
|
|
240
|
+
|
|
241
|
+
apply(current);
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
Math.abs(velocity) <= restSpeed &&
|
|
245
|
+
Math.abs(current - to) <= restDelta
|
|
246
|
+
) {
|
|
247
|
+
apply(to);
|
|
248
|
+
onDone();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
schedule(tick);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
schedule(tick);
|
|
256
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { FrameDriver } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function defaultNow(): number {
|
|
4
|
+
return typeof performance !== "undefined" && performance.now
|
|
5
|
+
? performance.now()
|
|
6
|
+
: Date.now();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function defaultRaf(cb: (time: number) => void): number {
|
|
10
|
+
if (typeof requestAnimationFrame === "function") {
|
|
11
|
+
return requestAnimationFrame(cb);
|
|
12
|
+
}
|
|
13
|
+
// Node / SSR fallback ~60fps
|
|
14
|
+
return setTimeout(() => cb(defaultNow()), 1000 / 60) as unknown as number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultCaf(id: number): void {
|
|
18
|
+
if (typeof cancelAnimationFrame === "function") {
|
|
19
|
+
cancelAnimationFrame(id);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
clearTimeout(id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Active frame driver — replace in tests via `installDriver`. */
|
|
26
|
+
export let driver: FrameDriver = {
|
|
27
|
+
now: defaultNow,
|
|
28
|
+
raf: defaultRaf,
|
|
29
|
+
caf: defaultCaf,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Swap the frame driver (used by tests and advanced hosts). */
|
|
33
|
+
export function installDriver(next: FrameDriver): () => void {
|
|
34
|
+
const prev = driver;
|
|
35
|
+
driver = next;
|
|
36
|
+
return () => {
|
|
37
|
+
driver = prev;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic test clock: manual `advance(ms)` pumps animations.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const clock = createTestClock();
|
|
47
|
+
* const restore = installDriver(clock.driver);
|
|
48
|
+
* animate(x, 100, { duration: 100 });
|
|
49
|
+
* clock.advance(50);
|
|
50
|
+
* clock.advance(50);
|
|
51
|
+
* restore();
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function createTestClock(): {
|
|
55
|
+
driver: FrameDriver;
|
|
56
|
+
advance: (ms: number) => void;
|
|
57
|
+
readonly time: number;
|
|
58
|
+
} {
|
|
59
|
+
let time = 0;
|
|
60
|
+
let nextId = 1;
|
|
61
|
+
type Entry = { id: number; time: number; cb: (t: number) => void };
|
|
62
|
+
let queue: Entry[] = [];
|
|
63
|
+
|
|
64
|
+
const clockDriver: FrameDriver = {
|
|
65
|
+
now: () => time,
|
|
66
|
+
raf(cb) {
|
|
67
|
+
const id = nextId++;
|
|
68
|
+
// Schedule for "next frame" — advanced when time moves.
|
|
69
|
+
queue.push({ id, time: time, cb });
|
|
70
|
+
return id;
|
|
71
|
+
},
|
|
72
|
+
caf(id) {
|
|
73
|
+
queue = queue.filter((e) => e.id !== id);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const advance = (ms: number): void => {
|
|
78
|
+
if (ms < 0) return;
|
|
79
|
+
const target = time + ms;
|
|
80
|
+
// Process frames in small steps so springs/tweens see intermediate times.
|
|
81
|
+
const step = 16.6667;
|
|
82
|
+
while (time < target) {
|
|
83
|
+
const nextTime = Math.min(time + step, target);
|
|
84
|
+
time = nextTime;
|
|
85
|
+
const batch = queue;
|
|
86
|
+
queue = [];
|
|
87
|
+
for (const entry of batch) {
|
|
88
|
+
entry.cb(time);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
driver: clockDriver,
|
|
95
|
+
advance,
|
|
96
|
+
get time() {
|
|
97
|
+
return time;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/easings.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Ease, EaseName, EasingFn } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Cubic-bezier approximation helpers via simple polynomials (good enough, tiny). */
|
|
4
|
+
|
|
5
|
+
const easings: Record<EaseName, EasingFn> = {
|
|
6
|
+
linear: (t) => t,
|
|
7
|
+
easeIn: (t) => t * t,
|
|
8
|
+
easeOut: (t) => 1 - (1 - t) * (1 - t),
|
|
9
|
+
easeInOut: (t) =>
|
|
10
|
+
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
|
|
11
|
+
easeInCubic: (t) => t * t * t,
|
|
12
|
+
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
|
|
13
|
+
easeInOutCubic: (t) =>
|
|
14
|
+
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
|
|
15
|
+
easeInBack: (t) => {
|
|
16
|
+
const c1 = 1.70158;
|
|
17
|
+
const c3 = c1 + 1;
|
|
18
|
+
return c3 * t * t * t - c1 * t * t;
|
|
19
|
+
},
|
|
20
|
+
easeOutBack: (t) => {
|
|
21
|
+
const c1 = 1.70158;
|
|
22
|
+
const c3 = c1 + 1;
|
|
23
|
+
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
24
|
+
},
|
|
25
|
+
easeInOutBack: (t) => {
|
|
26
|
+
const c1 = 1.70158;
|
|
27
|
+
const c2 = c1 * 1.525;
|
|
28
|
+
return t < 0.5
|
|
29
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
30
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Resolve a named or custom ease to a function. */
|
|
35
|
+
export function resolveEase(ease: Ease = "easeOut"): EasingFn {
|
|
36
|
+
if (typeof ease === "function") return ease;
|
|
37
|
+
return easings[ease] ?? easings.easeOut;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** List of built-in ease names (docs / tooling). */
|
|
41
|
+
export const easeNames = Object.keys(easings) as EaseName[];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSAP ↔ Powers signal bridge (no hard dependency on `gsap`).
|
|
3
|
+
* Use `createGsapBridge(gsap)` or the default export from `@lab206/animate/gsap`.
|
|
4
|
+
*/
|
|
5
|
+
import type { Signal } from "@lab206/core";
|
|
6
|
+
import { createPlayback } from "./playback.js";
|
|
7
|
+
import { clearActive, getActive, setActive } from "./registry.js";
|
|
8
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
9
|
+
import type { AnimationPlayback } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** Minimal tween handle — real GSAP Tween.kill has a wider signature. */
|
|
12
|
+
export type GsapTweenLike = {
|
|
13
|
+
kill: (...args: unknown[]) => unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Minimal GSAP surface we need — keeps peer types loose. */
|
|
17
|
+
export type GsapLike = {
|
|
18
|
+
to(target: object, vars: Record<string, unknown>): GsapTweenLike;
|
|
19
|
+
fromTo?(
|
|
20
|
+
target: object,
|
|
21
|
+
fromVars: Record<string, unknown>,
|
|
22
|
+
toVars: Record<string, unknown>,
|
|
23
|
+
): GsapTweenLike;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type GsapAnimateOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* Duration in **milliseconds** (same unit as `animate()`).
|
|
29
|
+
* Converted to seconds for GSAP. Default: 300.
|
|
30
|
+
*/
|
|
31
|
+
duration?: number;
|
|
32
|
+
/** Delay in milliseconds. Default: 0. */
|
|
33
|
+
delay?: number;
|
|
34
|
+
/** GSAP ease string, e.g. `"power2.out"`, `"elastic.out(1, 0.4)"`. */
|
|
35
|
+
ease?: string;
|
|
36
|
+
/** Override start value (defaults to `target.peek()`). */
|
|
37
|
+
from?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Honor `prefers-reduced-motion` (jump to end). Default: true.
|
|
40
|
+
*/
|
|
41
|
+
respectReducedMotion?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Extra GSAP tween vars (overwrite, yoyo, repeat, stagger on multi-targets, …).
|
|
44
|
+
* Do not set `onUpdate` / `onComplete` here — use the callbacks below.
|
|
45
|
+
*/
|
|
46
|
+
vars?: Record<string, unknown>;
|
|
47
|
+
onUpdate?: (value: number) => void;
|
|
48
|
+
onComplete?: () => void;
|
|
49
|
+
onCancel?: () => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type GsapBridge = {
|
|
53
|
+
/**
|
|
54
|
+
* Animate a number signal with GSAP.
|
|
55
|
+
* Same interrupt model as `animate()`: a new call cancels the previous on that signal.
|
|
56
|
+
*/
|
|
57
|
+
gsapAnimate: (
|
|
58
|
+
target: Signal<number>,
|
|
59
|
+
to: number,
|
|
60
|
+
options?: GsapAnimateOptions,
|
|
61
|
+
) => AnimationPlayback;
|
|
62
|
+
/**
|
|
63
|
+
* Explicit from → to with GSAP (`fromTo`).
|
|
64
|
+
*/
|
|
65
|
+
gsapFromTo: (
|
|
66
|
+
target: Signal<number>,
|
|
67
|
+
from: number,
|
|
68
|
+
to: number,
|
|
69
|
+
options?: GsapAnimateOptions,
|
|
70
|
+
) => AnimationPlayback;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bind a GSAP instance (or mock) to Powers signals.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* import gsap from "gsap";
|
|
79
|
+
* import { createGsapBridge } from "@lab206/animate/gsap";
|
|
80
|
+
*
|
|
81
|
+
* const { gsapAnimate } = createGsapBridge(gsap);
|
|
82
|
+
* gsapAnimate(x, 100, { duration: 400, ease: "power3.out" });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createGsapBridge(gsap: GsapLike): GsapBridge {
|
|
86
|
+
function run(
|
|
87
|
+
target: Signal<number>,
|
|
88
|
+
from: number,
|
|
89
|
+
to: number,
|
|
90
|
+
options: GsapAnimateOptions,
|
|
91
|
+
): AnimationPlayback {
|
|
92
|
+
getActive(target)?.cancel();
|
|
93
|
+
|
|
94
|
+
const respect =
|
|
95
|
+
options.respectReducedMotion !== false && prefersReducedMotion();
|
|
96
|
+
|
|
97
|
+
let tween: GsapTweenLike | null = null;
|
|
98
|
+
let finishedCleanup = false;
|
|
99
|
+
|
|
100
|
+
const finishClean = () => {
|
|
101
|
+
if (finishedCleanup) return;
|
|
102
|
+
finishedCleanup = true;
|
|
103
|
+
clearActive(target, playback);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const apply = (value: number) => {
|
|
107
|
+
target.set(value);
|
|
108
|
+
options.onUpdate?.(value);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const playback = createPlayback({
|
|
112
|
+
onCancel: () => {
|
|
113
|
+
tween?.kill();
|
|
114
|
+
tween = null;
|
|
115
|
+
finishClean();
|
|
116
|
+
options.onCancel?.();
|
|
117
|
+
},
|
|
118
|
+
onComplete: () => {
|
|
119
|
+
apply(to);
|
|
120
|
+
tween = null;
|
|
121
|
+
finishClean();
|
|
122
|
+
options.onComplete?.();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
setActive(target, playback);
|
|
127
|
+
|
|
128
|
+
if (respect || from === to) {
|
|
129
|
+
apply(to);
|
|
130
|
+
playback.complete();
|
|
131
|
+
return playback;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const proxy = { v: from };
|
|
135
|
+
apply(from);
|
|
136
|
+
|
|
137
|
+
const durationSec = Math.max(0, (options.duration ?? 300) / 1000);
|
|
138
|
+
const delaySec = Math.max(0, (options.delay ?? 0) / 1000);
|
|
139
|
+
// Strip reserved keys so callers cannot clobber signal wiring
|
|
140
|
+
const extra = { ...(options.vars ?? {}) };
|
|
141
|
+
delete extra.v;
|
|
142
|
+
delete extra.onUpdate;
|
|
143
|
+
delete extra.onComplete;
|
|
144
|
+
delete extra.onInterrupt;
|
|
145
|
+
delete extra.duration;
|
|
146
|
+
delete extra.delay;
|
|
147
|
+
delete extra.ease;
|
|
148
|
+
|
|
149
|
+
const toVars: Record<string, unknown> = {
|
|
150
|
+
...extra,
|
|
151
|
+
v: to,
|
|
152
|
+
duration: durationSec,
|
|
153
|
+
delay: delaySec,
|
|
154
|
+
ease: options.ease ?? "power2.out",
|
|
155
|
+
onUpdate: () => {
|
|
156
|
+
apply(proxy.v);
|
|
157
|
+
},
|
|
158
|
+
onComplete: () => {
|
|
159
|
+
if (playback.playState !== "running") return;
|
|
160
|
+
playback.complete();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (typeof gsap.fromTo === "function") {
|
|
165
|
+
tween = gsap.fromTo(proxy, { v: from }, toVars);
|
|
166
|
+
} else {
|
|
167
|
+
tween = gsap.to(proxy, toVars);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return playback;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
gsapAnimate(target, to, options = {}) {
|
|
175
|
+
const from =
|
|
176
|
+
options.from !== undefined ? options.from : target.peek();
|
|
177
|
+
return run(target, from, to, options);
|
|
178
|
+
},
|
|
179
|
+
gsapFromTo(target, from, to, options = {}) {
|
|
180
|
+
return run(target, from, to, { ...options, from });
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/gsap.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional GSAP adapter for Powers.
|
|
3
|
+
*
|
|
4
|
+
* Install peer: `pnpm add gsap`
|
|
5
|
+
* Import: `import { gsapAnimate } from "@lab206/animate/gsap"`
|
|
6
|
+
*
|
|
7
|
+
* Default motion remains `@lab206/animate` (no GSAP required).
|
|
8
|
+
* Use this for timelines, custom eases, ScrollTrigger (via `vars`), etc.
|
|
9
|
+
*/
|
|
10
|
+
import gsap from "gsap";
|
|
11
|
+
import { createGsapBridge, type GsapLike } from "./gsap-bridge.js";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
createGsapBridge,
|
|
15
|
+
type GsapLike,
|
|
16
|
+
type GsapAnimateOptions,
|
|
17
|
+
type GsapBridge,
|
|
18
|
+
} from "./gsap-bridge.js";
|
|
19
|
+
|
|
20
|
+
// Cast: we only use to/fromTo + tween.kill
|
|
21
|
+
const bridge = createGsapBridge(gsap as unknown as GsapLike);
|
|
22
|
+
|
|
23
|
+
/** Animate a number signal with GSAP (duration in ms, same as `animate()`). */
|
|
24
|
+
export const gsapAnimate = bridge.gsapAnimate;
|
|
25
|
+
|
|
26
|
+
/** GSAP `fromTo` for a number signal. */
|
|
27
|
+
export const gsapFromTo = bridge.gsapFromTo;
|
|
28
|
+
|
|
29
|
+
/** Re-export native cancel so one import path covers both engines. */
|
|
30
|
+
export { cancel } from "./registry.js";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lab206/animate
|
|
3
|
+
*
|
|
4
|
+
* Signal-native motion. Learn it in one line:
|
|
5
|
+
* animate(mySignal, 100, { duration: 300 })
|
|
6
|
+
*
|
|
7
|
+
* Optional spring feel:
|
|
8
|
+
* animate(mySignal, 100, spring())
|
|
9
|
+
*
|
|
10
|
+
* Optional GSAP (peer dependency):
|
|
11
|
+
* import { gsapAnimate } from "@lab206/animate/gsap"
|
|
12
|
+
* — or createGsapBridge(gsap) from this package without importing gsap here
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { animate, spring } from "./animate.js";
|
|
16
|
+
export { cancel } from "./registry.js";
|
|
17
|
+
export {
|
|
18
|
+
prefersReducedMotion,
|
|
19
|
+
setReducedMotionOverride,
|
|
20
|
+
} from "./reduced-motion.js";
|
|
21
|
+
export {
|
|
22
|
+
installDriver,
|
|
23
|
+
createTestClock,
|
|
24
|
+
driver,
|
|
25
|
+
} from "./driver.js";
|
|
26
|
+
export { resolveEase, easeNames } from "./easings.js";
|
|
27
|
+
export { createGsapBridge } from "./gsap-bridge.js";
|
|
28
|
+
export type {
|
|
29
|
+
GsapLike,
|
|
30
|
+
GsapAnimateOptions,
|
|
31
|
+
GsapBridge,
|
|
32
|
+
} from "./gsap-bridge.js";
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
AnimateOptions,
|
|
36
|
+
AnimationPlayback,
|
|
37
|
+
Animatable,
|
|
38
|
+
Ease,
|
|
39
|
+
EaseName,
|
|
40
|
+
EasingFn,
|
|
41
|
+
FrameDriver,
|
|
42
|
+
PlayState,
|
|
43
|
+
SpringOptions,
|
|
44
|
+
TweenOptions,
|
|
45
|
+
} from "./types.js";
|