@cioky/ripple-transitions 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/README.md +67 -0
- package/package.json +31 -0
- package/src/index.tsrx +37 -0
- package/src/layout.ts +247 -0
- package/src/motion.ts +812 -0
- package/src/presence.tsrx +286 -0
- package/src/slide.ts +220 -0
- package/src/spring.ts +165 -0
- package/src/stagger.ts +64 -0
- package/src/types.ts +48 -0
package/src/motion.ts
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { Context } from 'ripple';
|
|
2
|
+
import {
|
|
3
|
+
type MotionTiming,
|
|
4
|
+
type MotionConfig,
|
|
5
|
+
type TransitionOptions,
|
|
6
|
+
type GestureConfig,
|
|
7
|
+
type SvelteTransitionConfig,
|
|
8
|
+
type SvelteTransitionFn
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import { getSpringKeyframes } from './spring.js';
|
|
11
|
+
|
|
12
|
+
export const MotionTimingContext = new Context<MotionTiming | null>(null);
|
|
13
|
+
|
|
14
|
+
export function mergeRefs(...callbacks: Array<((el: any) => any) | null | undefined>) {
|
|
15
|
+
return (el: HTMLElement) => {
|
|
16
|
+
if (!el) return;
|
|
17
|
+
const cleanups = callbacks
|
|
18
|
+
.map(cb => cb && cb(el))
|
|
19
|
+
.filter((c): c is () => void => typeof c === 'function');
|
|
20
|
+
return () => {
|
|
21
|
+
for (const cleanup of cleanups) {
|
|
22
|
+
try {
|
|
23
|
+
cleanup();
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error('Error during ref cleanup:', e);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withTiming(options: TransitionOptions = {}): TransitionOptions {
|
|
33
|
+
try {
|
|
34
|
+
const ctx = MotionTimingContext.get();
|
|
35
|
+
if (!ctx) return options;
|
|
36
|
+
return {
|
|
37
|
+
delay: options.delay ?? 0,
|
|
38
|
+
duration: options.duration ?? ctx.duration,
|
|
39
|
+
type: options.type ?? ctx.type,
|
|
40
|
+
stiffness: options.stiffness ?? ctx.stiffness,
|
|
41
|
+
damping: options.damping ?? ctx.damping,
|
|
42
|
+
mass: options.mass ?? ctx.mass,
|
|
43
|
+
easing: options.easing ?? ctx.easing,
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return options;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCurrentState(target: HTMLElement, keyframe: any): Record<string, any> {
|
|
51
|
+
const cs = getComputedStyle(target);
|
|
52
|
+
const state: Record<string, any> = {};
|
|
53
|
+
if (keyframe && typeof keyframe === 'object') {
|
|
54
|
+
for (const key of Object.keys(keyframe)) {
|
|
55
|
+
const val = (cs as any)[key];
|
|
56
|
+
if (val !== undefined && val !== '') {
|
|
57
|
+
state[key] = /^\d/.test(val) ? parseFloat(val) : val;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function logTransition(el: HTMLElement, phase: string, details: Record<string, any>) {
|
|
65
|
+
if (typeof window === 'undefined') return;
|
|
66
|
+
const isDebugMode = (window as any).__ripple_debug || window.location.search.includes('debug');
|
|
67
|
+
if (!isDebugMode) return;
|
|
68
|
+
|
|
69
|
+
const name = el.className ? `.${el.className.split(' ').slice(0,2).join('.')}` : el.tagName.toLowerCase();
|
|
70
|
+
console.log(
|
|
71
|
+
`%c[Ripple Transitions] %c${phase} on %c${name}%c\n` +
|
|
72
|
+
Object.entries(details).map(([k, v]) => ` └─ ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n'),
|
|
73
|
+
"color: #00ffcc; font-weight: bold;",
|
|
74
|
+
"color: #fff; font-weight: bold;",
|
|
75
|
+
"color: #ffcc00; font-weight: bold;",
|
|
76
|
+
"color: #ccc;"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
function applyStyles(target: HTMLElement, styles: Record<string, any>) {
|
|
80
|
+
for (const [key, val] of Object.entries(styles)) {
|
|
81
|
+
if (val === undefined || val === null) continue;
|
|
82
|
+
if (key === 'transform' || key === 'opacity' || key === 'overflow') {
|
|
83
|
+
(target.style as any)[key] = String(val);
|
|
84
|
+
} else {
|
|
85
|
+
(target.style as any)[key] = typeof val === 'number' ? `${val}px` : String(val);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function motion(config: MotionConfig) {
|
|
90
|
+
const { initial, animate, exit } = config;
|
|
91
|
+
|
|
92
|
+
return (el: HTMLElement) => {
|
|
93
|
+
if (!el) return;
|
|
94
|
+
|
|
95
|
+
const transition = withTiming(config.transition || {});
|
|
96
|
+
const {
|
|
97
|
+
delay = 0,
|
|
98
|
+
duration = 300,
|
|
99
|
+
easing = 'cubic-bezier(0.25, 1, 0.5, 1)',
|
|
100
|
+
type,
|
|
101
|
+
stiffness = 300,
|
|
102
|
+
damping = 30,
|
|
103
|
+
mass = 1
|
|
104
|
+
} = transition;
|
|
105
|
+
const isSpring = type === 'spring';
|
|
106
|
+
|
|
107
|
+
const mountSpring = isSpring && initial && animate
|
|
108
|
+
? getSpringKeyframes(initial, animate, { delay, duration, easing, type, stiffness, damping, mass } as Required<TransitionOptions>)
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
const exitSpring = isSpring && animate && exit
|
|
112
|
+
? getSpringKeyframes(animate, exit, { delay, duration, easing, type, stiffness, damping, mass } as Required<TransitionOptions>)
|
|
113
|
+
: null;
|
|
114
|
+
|
|
115
|
+
// Enter handler (supports interruption)
|
|
116
|
+
const enterFn = (target: HTMLElement) => {
|
|
117
|
+
const prevExitAnim = (target as any).__ripple_exit_anim;
|
|
118
|
+
if (prevExitAnim && prevExitAnim.playState !== 'finished') {
|
|
119
|
+
// Interrupted during exit: read current style, calculate progress, and animate from it
|
|
120
|
+
const progress = prevExitAnim.currentTime ? Math.max(0, Math.min(1, Number(prevExitAnim.currentTime) / duration)) : 0.5;
|
|
121
|
+
const adjDuration = Math.max(50, duration * progress);
|
|
122
|
+
|
|
123
|
+
const currentState = getCurrentState(target, animate);
|
|
124
|
+
prevExitAnim.cancel();
|
|
125
|
+
delete (target as any).__ripple_exit_anim;
|
|
126
|
+
|
|
127
|
+
if (Object.keys(currentState).length > 0) {
|
|
128
|
+
applyStyles(target, currentState);
|
|
129
|
+
const mountKeyframes = isSpring
|
|
130
|
+
? getSpringKeyframes(currentState, animate, { delay, duration: adjDuration, easing, type, stiffness, damping, mass } as Required<TransitionOptions>)
|
|
131
|
+
: [currentState, animate ?? {}];
|
|
132
|
+
|
|
133
|
+
logTransition(target, 'Enter (Interrupted Exit)', {
|
|
134
|
+
duration: adjDuration,
|
|
135
|
+
delay,
|
|
136
|
+
startState: currentState,
|
|
137
|
+
endState: animate
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const mountAnim = target.animate(mountKeyframes, {
|
|
141
|
+
delay,
|
|
142
|
+
duration: adjDuration,
|
|
143
|
+
easing: isSpring ? 'linear' : easing,
|
|
144
|
+
fill: 'both'
|
|
145
|
+
});
|
|
146
|
+
(target as any).__ripple_anim = mountAnim;
|
|
147
|
+
mountAnim.finished.then(() => {
|
|
148
|
+
if ((target as any).__ripple_anim === mountAnim) {
|
|
149
|
+
try { mountAnim.commitStyles(); mountAnim.cancel(); } catch {}
|
|
150
|
+
target.style.transform = '';
|
|
151
|
+
target.style.opacity = '';
|
|
152
|
+
delete (target as any).__ripple_anim;
|
|
153
|
+
}
|
|
154
|
+
}).catch(() => {});
|
|
155
|
+
|
|
156
|
+
return mountAnim.finished;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Normal enter
|
|
161
|
+
const prevAnim = (target as any).__ripple_anim;
|
|
162
|
+
if (prevAnim && prevAnim.playState !== 'finished') {
|
|
163
|
+
prevAnim.cancel();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (initial && animate) {
|
|
167
|
+
applyStyles(target, initial);
|
|
168
|
+
const mountKeyframes = mountSpring ?? [initial, animate];
|
|
169
|
+
logTransition(target, 'Enter (Normal)', {
|
|
170
|
+
duration,
|
|
171
|
+
delay,
|
|
172
|
+
startState: initial,
|
|
173
|
+
endState: animate
|
|
174
|
+
});
|
|
175
|
+
const mountAnim = target.animate(mountKeyframes, {
|
|
176
|
+
delay,
|
|
177
|
+
duration,
|
|
178
|
+
easing: isSpring ? 'linear' : easing,
|
|
179
|
+
fill: 'both'
|
|
180
|
+
});
|
|
181
|
+
(target as any).__ripple_anim = mountAnim;
|
|
182
|
+
|
|
183
|
+
mountAnim.finished.then(() => {
|
|
184
|
+
if ((target as any).__ripple_anim === mountAnim) {
|
|
185
|
+
try { mountAnim.commitStyles(); mountAnim.cancel(); } catch {}
|
|
186
|
+
target.style.transform = '';
|
|
187
|
+
target.style.opacity = '';
|
|
188
|
+
delete (target as any).__ripple_anim;
|
|
189
|
+
}
|
|
190
|
+
}).catch(() => {});
|
|
191
|
+
|
|
192
|
+
return mountAnim.finished;
|
|
193
|
+
}
|
|
194
|
+
return Promise.resolve();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Exit handler (supports interruption)
|
|
198
|
+
const exitFn = (target: HTMLElement) => {
|
|
199
|
+
const prevAnim = (target as any).__ripple_anim;
|
|
200
|
+
if (prevAnim && prevAnim.playState !== 'finished') {
|
|
201
|
+
// Interrupted during enter: read current style, calculate progress, and animate from it
|
|
202
|
+
const progress = prevAnim.currentTime ? Math.max(0, Math.min(1, Number(prevAnim.currentTime) / duration)) : 0.5;
|
|
203
|
+
const adjDuration = Math.max(50, duration * progress);
|
|
204
|
+
|
|
205
|
+
const currentState = getCurrentState(target, exit ?? initial ?? {});
|
|
206
|
+
prevAnim.cancel();
|
|
207
|
+
delete (target as any).__ripple_anim;
|
|
208
|
+
|
|
209
|
+
if (Object.keys(currentState).length > 0 && exit) {
|
|
210
|
+
applyStyles(target, currentState);
|
|
211
|
+
const exitKeyframes = isSpring
|
|
212
|
+
? getSpringKeyframes(currentState, exit, { delay, duration: adjDuration, easing, type, stiffness, damping, mass } as Required<TransitionOptions>)
|
|
213
|
+
: [currentState, exit];
|
|
214
|
+
|
|
215
|
+
logTransition(target, 'Exit (Interrupted Enter)', {
|
|
216
|
+
duration: adjDuration,
|
|
217
|
+
delay,
|
|
218
|
+
startState: currentState,
|
|
219
|
+
endState: exit
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const exitAnim = target.animate(exitKeyframes, {
|
|
223
|
+
delay,
|
|
224
|
+
duration: adjDuration,
|
|
225
|
+
easing: isSpring ? 'linear' : easing,
|
|
226
|
+
fill: 'both'
|
|
227
|
+
});
|
|
228
|
+
(target as any).__ripple_exit_anim = exitAnim;
|
|
229
|
+
exitAnim.finished.then(() => {
|
|
230
|
+
if ((target as any).__ripple_exit_anim === exitAnim) {
|
|
231
|
+
try { exitAnim.commitStyles(); exitAnim.cancel(); } catch {}
|
|
232
|
+
delete (target as any).__ripple_exit_anim;
|
|
233
|
+
}
|
|
234
|
+
}).catch(() => {});
|
|
235
|
+
return exitAnim.finished;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Normal exit
|
|
240
|
+
const prevExitAnim = (target as any).__ripple_exit_anim;
|
|
241
|
+
if (prevExitAnim && prevExitAnim.playState !== 'finished') {
|
|
242
|
+
prevExitAnim.cancel();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (animate && exit) {
|
|
246
|
+
applyStyles(target, animate);
|
|
247
|
+
const exitKeyframes = exitSpring ?? [animate, exit];
|
|
248
|
+
logTransition(target, 'Exit (Normal)', {
|
|
249
|
+
duration,
|
|
250
|
+
delay,
|
|
251
|
+
startState: animate,
|
|
252
|
+
endState: exit
|
|
253
|
+
});
|
|
254
|
+
const exitAnim = target.animate(exitKeyframes, {
|
|
255
|
+
delay,
|
|
256
|
+
duration,
|
|
257
|
+
easing: isSpring ? 'linear' : easing,
|
|
258
|
+
fill: 'both'
|
|
259
|
+
});
|
|
260
|
+
(target as any).__ripple_exit_anim = exitAnim;
|
|
261
|
+
exitAnim.finished.then(() => {
|
|
262
|
+
if ((target as any).__ripple_exit_anim === exitAnim) {
|
|
263
|
+
try { exitAnim.commitStyles(); exitAnim.cancel(); } catch {}
|
|
264
|
+
delete (target as any).__ripple_exit_anim;
|
|
265
|
+
}
|
|
266
|
+
}).catch(() => {});
|
|
267
|
+
return exitAnim.finished;
|
|
268
|
+
}
|
|
269
|
+
return Promise.resolve();
|
|
270
|
+
};
|
|
271
|
+
// Register handlers on element
|
|
272
|
+
(el as any).__ripple_enter = enterFn;
|
|
273
|
+
(el as any).__ripple_exit = exitFn;
|
|
274
|
+
|
|
275
|
+
// Run enter animation immediately on mount
|
|
276
|
+
(el as any).__ripple_mounted = true;
|
|
277
|
+
enterFn(el);
|
|
278
|
+
|
|
279
|
+
return () => {
|
|
280
|
+
const a = (el as any).__ripple_anim;
|
|
281
|
+
if (a && a.playState !== 'finished') a.cancel();
|
|
282
|
+
const ea = (el as any).__ripple_exit_anim;
|
|
283
|
+
if (ea && ea.playState !== 'finished') ea.cancel();
|
|
284
|
+
|
|
285
|
+
delete (el as any).__ripple_enter;
|
|
286
|
+
delete (el as any).__ripple_exit;
|
|
287
|
+
delete (el as any).__ripple_anim;
|
|
288
|
+
delete (el as any).__ripple_exit_anim;
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function fade(options: TransitionOptions = {}) {
|
|
294
|
+
return motion({
|
|
295
|
+
initial: { opacity: 0 },
|
|
296
|
+
animate: { opacity: 1 },
|
|
297
|
+
exit: { opacity: 0 },
|
|
298
|
+
transition: options
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function rise(options: TransitionOptions = {}) {
|
|
303
|
+
return motion({
|
|
304
|
+
initial: { opacity: 0, transform: 'translateY(20px)' },
|
|
305
|
+
animate: { opacity: 1, transform: 'translateY(0)' },
|
|
306
|
+
exit: { opacity: 0, transform: 'translateY(20px)' },
|
|
307
|
+
transition: options
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function scale(options: TransitionOptions = {}) {
|
|
312
|
+
return motion({
|
|
313
|
+
initial: { opacity: 0, transform: 'scale(0.95)' },
|
|
314
|
+
animate: { opacity: 1, transform: 'scale(1)' },
|
|
315
|
+
exit: { opacity: 0, transform: 'scale(0.95)' },
|
|
316
|
+
transition: options
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function fly(options: TransitionOptions & { x?: number; y?: number; exitX?: number; exitY?: number } = {}) {
|
|
321
|
+
const { x = 0, y = 0, exitX, exitY, ...transitionOpts } = options;
|
|
322
|
+
return motion({
|
|
323
|
+
initial: { opacity: 0, transform: `translate(${x}px, ${y}px)` },
|
|
324
|
+
animate: { opacity: 1, transform: 'translate(0, 0)' },
|
|
325
|
+
exit: { opacity: 0, transform: `translate(${exitX ?? x}px, ${exitY ?? y}px)` },
|
|
326
|
+
transition: transitionOpts
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function blur(options: TransitionOptions & { amount?: number } = {}) {
|
|
331
|
+
const { amount = 5, ...transitionOpts } = options;
|
|
332
|
+
return motion({
|
|
333
|
+
initial: { opacity: 0, filter: `blur(${amount}px)` },
|
|
334
|
+
animate: { opacity: 1, filter: 'blur(0px)' },
|
|
335
|
+
exit: { opacity: 0, filter: `blur(${amount}px)` },
|
|
336
|
+
transition: transitionOpts
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function draw(options: TransitionOptions & { speed?: number } = {}) {
|
|
341
|
+
return (el: HTMLElement) => {
|
|
342
|
+
if (!el) return;
|
|
343
|
+
const isSvgPath = el instanceof SVGPathElement || el instanceof SVGPolylineElement;
|
|
344
|
+
const length = isSvgPath ? (el as any).getTotalLength() : 0;
|
|
345
|
+
|
|
346
|
+
if (isSvgPath) {
|
|
347
|
+
el.style.strokeDasharray = String(length);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const transition = withTiming(options);
|
|
351
|
+
const duration = options.duration ?? (options.speed && length ? length / options.speed : 800);
|
|
352
|
+
|
|
353
|
+
return motion({
|
|
354
|
+
initial: { strokeDashoffset: length, opacity: 0 },
|
|
355
|
+
animate: { strokeDashoffset: 0, opacity: 1 },
|
|
356
|
+
exit: { strokeDashoffset: length, opacity: 0 },
|
|
357
|
+
transition: { ...transition, duration }
|
|
358
|
+
})(el);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function parseStyleString(cssText: string): Record<string, string> {
|
|
363
|
+
const styles: Record<string, string> = {};
|
|
364
|
+
const declarations = cssText.split(';');
|
|
365
|
+
for (const decl of declarations) {
|
|
366
|
+
const colon = decl.indexOf(':');
|
|
367
|
+
if (colon === -1) continue;
|
|
368
|
+
const key = decl.slice(0, colon).trim();
|
|
369
|
+
const value = decl.slice(colon + 1).trim();
|
|
370
|
+
if (key && value) {
|
|
371
|
+
styles[key] = value;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return styles;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function svelteTransition(transitionFn: SvelteTransitionFn, params?: any) {
|
|
378
|
+
return (el: HTMLElement) => {
|
|
379
|
+
if (!el) return;
|
|
380
|
+
const config = transitionFn(el, params);
|
|
381
|
+
const delay = config.delay ?? 0;
|
|
382
|
+
const duration = config.duration ?? 400;
|
|
383
|
+
const easingFn = config.easing ?? ((t: number) => t);
|
|
384
|
+
|
|
385
|
+
const steps = 30;
|
|
386
|
+
const enterKeyframes: Record<string, any>[] = [];
|
|
387
|
+
const exitKeyframes: Record<string, any>[] = [];
|
|
388
|
+
|
|
389
|
+
if (config.css) {
|
|
390
|
+
for (let i = 0; i <= steps; i++) {
|
|
391
|
+
const pct = i / steps;
|
|
392
|
+
const t = easingFn(pct);
|
|
393
|
+
const u = 1 - t;
|
|
394
|
+
const cssText = config.css(t, u);
|
|
395
|
+
const styles = parseStyleString(cssText);
|
|
396
|
+
enterKeyframes.push({ ...styles, offset: pct });
|
|
397
|
+
}
|
|
398
|
+
for (let i = 0; i <= steps; i++) {
|
|
399
|
+
const pct = i / steps;
|
|
400
|
+
const t = easingFn(1 - pct);
|
|
401
|
+
const u = 1 - t;
|
|
402
|
+
const cssText = config.css(t, u);
|
|
403
|
+
const styles = parseStyleString(cssText);
|
|
404
|
+
exitKeyframes.push({ ...styles, offset: pct });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let enterTickFrame: number | null = null;
|
|
409
|
+
const startEnterTick = () => {
|
|
410
|
+
if (!config.tick) return;
|
|
411
|
+
if (enterTickFrame) cancelAnimationFrame(enterTickFrame);
|
|
412
|
+
let startTime: number | null = null;
|
|
413
|
+
const run = (timestamp: number) => {
|
|
414
|
+
if (!startTime) startTime = timestamp;
|
|
415
|
+
const elapsed = timestamp - startTime;
|
|
416
|
+
const pct = Math.min(1, elapsed / duration);
|
|
417
|
+
const t = easingFn(pct);
|
|
418
|
+
config.tick!(t, 1 - t);
|
|
419
|
+
if (pct < 1) {
|
|
420
|
+
enterTickFrame = requestAnimationFrame(run);
|
|
421
|
+
} else {
|
|
422
|
+
enterTickFrame = null;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
enterTickFrame = requestAnimationFrame(run);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
let exitTickFrame: number | null = null;
|
|
429
|
+
const startExitTick = () => {
|
|
430
|
+
if (!config.tick) return;
|
|
431
|
+
if (exitTickFrame) cancelAnimationFrame(exitTickFrame);
|
|
432
|
+
let startTime: number | null = null;
|
|
433
|
+
const run = (timestamp: number) => {
|
|
434
|
+
if (!startTime) startTime = timestamp;
|
|
435
|
+
const elapsed = timestamp - startTime;
|
|
436
|
+
const pct = Math.min(1, elapsed / duration);
|
|
437
|
+
const t = easingFn(1 - pct);
|
|
438
|
+
config.tick!(t, 1 - t);
|
|
439
|
+
if (pct < 1) {
|
|
440
|
+
exitTickFrame = requestAnimationFrame(run);
|
|
441
|
+
} else {
|
|
442
|
+
exitTickFrame = null;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
exitTickFrame = requestAnimationFrame(run);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const enterFn = (target: HTMLElement) => {
|
|
449
|
+
const prevExitAnim = (target as any).__ripple_exit_anim;
|
|
450
|
+
if (prevExitAnim) {
|
|
451
|
+
prevExitAnim.cancel();
|
|
452
|
+
delete (target as any).__ripple_exit_anim;
|
|
453
|
+
}
|
|
454
|
+
if (exitTickFrame) {
|
|
455
|
+
cancelAnimationFrame(exitTickFrame);
|
|
456
|
+
exitTickFrame = null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
startEnterTick();
|
|
460
|
+
|
|
461
|
+
if (config.css && enterKeyframes.length > 0) {
|
|
462
|
+
const anim = target.animate(enterKeyframes, {
|
|
463
|
+
delay,
|
|
464
|
+
duration,
|
|
465
|
+
fill: 'both'
|
|
466
|
+
});
|
|
467
|
+
(target as any).__ripple_anim = anim;
|
|
468
|
+
anim.finished.then(() => {
|
|
469
|
+
if ((target as any).__ripple_anim === anim) {
|
|
470
|
+
try { anim.commitStyles(); anim.cancel(); } catch {}
|
|
471
|
+
delete (target as any).__ripple_anim;
|
|
472
|
+
}
|
|
473
|
+
}).catch(() => {});
|
|
474
|
+
return anim.finished;
|
|
475
|
+
}
|
|
476
|
+
return Promise.resolve();
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const exitFn = (target: HTMLElement) => {
|
|
480
|
+
const prevAnim = (target as any).__ripple_anim;
|
|
481
|
+
if (prevAnim) {
|
|
482
|
+
prevAnim.cancel();
|
|
483
|
+
delete (target as any).__ripple_anim;
|
|
484
|
+
}
|
|
485
|
+
if (enterTickFrame) {
|
|
486
|
+
cancelAnimationFrame(enterTickFrame);
|
|
487
|
+
enterTickFrame = null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
startExitTick();
|
|
491
|
+
|
|
492
|
+
if (config.css && exitKeyframes.length > 0) {
|
|
493
|
+
const anim = target.animate(exitKeyframes, {
|
|
494
|
+
delay,
|
|
495
|
+
duration,
|
|
496
|
+
fill: 'both'
|
|
497
|
+
});
|
|
498
|
+
(target as any).__ripple_exit_anim = anim;
|
|
499
|
+
anim.finished.then(() => {
|
|
500
|
+
if ((target as any).__ripple_exit_anim === anim) {
|
|
501
|
+
try { anim.commitStyles(); anim.cancel(); } catch {}
|
|
502
|
+
delete (target as any).__ripple_exit_anim;
|
|
503
|
+
}
|
|
504
|
+
}).catch(() => {});
|
|
505
|
+
return anim.finished;
|
|
506
|
+
}
|
|
507
|
+
return Promise.resolve();
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
(el as any).__ripple_enter = enterFn;
|
|
511
|
+
(el as any).__ripple_exit = exitFn;
|
|
512
|
+
|
|
513
|
+
enterFn(el);
|
|
514
|
+
|
|
515
|
+
return () => {
|
|
516
|
+
const a = (el as any).__ripple_anim;
|
|
517
|
+
if (a) a.cancel();
|
|
518
|
+
const ea = (el as any).__ripple_exit_anim;
|
|
519
|
+
if (ea) ea.cancel();
|
|
520
|
+
if (enterTickFrame) cancelAnimationFrame(enterTickFrame);
|
|
521
|
+
if (exitTickFrame) cancelAnimationFrame(exitTickFrame);
|
|
522
|
+
delete (el as any).__ripple_enter;
|
|
523
|
+
delete (el as any).__ripple_exit;
|
|
524
|
+
delete (el as any).__ripple_anim;
|
|
525
|
+
delete (el as any).__ripple_exit_anim;
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function gestures(config: GestureConfig) {
|
|
531
|
+
return (el: HTMLElement) => {
|
|
532
|
+
if (!el) return;
|
|
533
|
+
|
|
534
|
+
const transition = withTiming(config.transition || {});
|
|
535
|
+
const {
|
|
536
|
+
duration = 200,
|
|
537
|
+
easing = 'ease-out',
|
|
538
|
+
type,
|
|
539
|
+
stiffness = 300,
|
|
540
|
+
damping = 30,
|
|
541
|
+
mass = 1
|
|
542
|
+
} = transition;
|
|
543
|
+
|
|
544
|
+
const isSpring = type === 'spring';
|
|
545
|
+
|
|
546
|
+
let currentHoverAnim: Animation | null = null;
|
|
547
|
+
let currentTapAnim: Animation | null = null;
|
|
548
|
+
let currentFocusAnim: Animation | null = null;
|
|
549
|
+
|
|
550
|
+
const getBaseStyles = (targetKeys: string[]) => {
|
|
551
|
+
const cs = getComputedStyle(el);
|
|
552
|
+
const base: Record<string, any> = {};
|
|
553
|
+
for (const key of targetKeys) {
|
|
554
|
+
const val = (cs as any)[key];
|
|
555
|
+
base[key] = val;
|
|
556
|
+
}
|
|
557
|
+
return base;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const animateTo = (targetState: Record<string, any>, durationMs: number) => {
|
|
561
|
+
const keys = Object.keys(targetState);
|
|
562
|
+
const startState = getBaseStyles(keys);
|
|
563
|
+
const keyframes = isSpring
|
|
564
|
+
? getSpringKeyframes(startState, targetState, { delay: 0, duration: durationMs, easing, type, stiffness, damping, mass } as Required<TransitionOptions>)
|
|
565
|
+
: [startState, targetState];
|
|
566
|
+
|
|
567
|
+
const anim = el.animate(keyframes, {
|
|
568
|
+
duration: durationMs,
|
|
569
|
+
easing: isSpring ? 'linear' : easing,
|
|
570
|
+
fill: 'both'
|
|
571
|
+
});
|
|
572
|
+
anim.finished.then(() => {
|
|
573
|
+
try { anim.commitStyles(); anim.cancel(); } catch {}
|
|
574
|
+
}).catch(() => {});
|
|
575
|
+
return anim;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
let isHovered = false;
|
|
579
|
+
const onMouseEnter = () => {
|
|
580
|
+
if (!config.hover) return;
|
|
581
|
+
isHovered = true;
|
|
582
|
+
if (currentHoverAnim) currentHoverAnim.cancel();
|
|
583
|
+
currentHoverAnim = animateTo(config.hover, duration);
|
|
584
|
+
};
|
|
585
|
+
const onMouseLeave = () => {
|
|
586
|
+
if (!config.hover) return;
|
|
587
|
+
isHovered = false;
|
|
588
|
+
if (currentHoverAnim) currentHoverAnim.cancel();
|
|
589
|
+
const keys = Object.keys(config.hover);
|
|
590
|
+
const originalStyles: Record<string, string> = {};
|
|
591
|
+
for (const key of keys) {
|
|
592
|
+
originalStyles[key] = el.style[key as any];
|
|
593
|
+
el.style[key as any] = '';
|
|
594
|
+
}
|
|
595
|
+
const baseState = getBaseStyles(keys);
|
|
596
|
+
for (const key of keys) {
|
|
597
|
+
el.style[key as any] = originalStyles[key];
|
|
598
|
+
}
|
|
599
|
+
currentHoverAnim = animateTo(baseState, duration);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
let isTapped = false;
|
|
603
|
+
const onMouseDown = () => {
|
|
604
|
+
if (!config.tap) return;
|
|
605
|
+
isTapped = true;
|
|
606
|
+
if (currentTapAnim) currentTapAnim.cancel();
|
|
607
|
+
currentTapAnim = animateTo(config.tap, duration * 0.7);
|
|
608
|
+
};
|
|
609
|
+
const onMouseUp = () => {
|
|
610
|
+
if (!config.tap) return;
|
|
611
|
+
if (!isTapped) return;
|
|
612
|
+
isTapped = false;
|
|
613
|
+
if (currentTapAnim) currentTapAnim.cancel();
|
|
614
|
+
const keys = Object.keys(config.tap);
|
|
615
|
+
const originalStyles: Record<string, string> = {};
|
|
616
|
+
for (const key of keys) {
|
|
617
|
+
originalStyles[key] = el.style[key as any];
|
|
618
|
+
el.style[key as any] = '';
|
|
619
|
+
}
|
|
620
|
+
const targetState: Record<string, any> = {};
|
|
621
|
+
const hoverState = config.hover || {};
|
|
622
|
+
const baseState = getBaseStyles(keys);
|
|
623
|
+
for (const key of keys) {
|
|
624
|
+
targetState[key] = isHovered && hoverState[key] !== undefined ? hoverState[key] : baseState[key];
|
|
625
|
+
}
|
|
626
|
+
for (const key of keys) {
|
|
627
|
+
el.style[key as any] = originalStyles[key];
|
|
628
|
+
}
|
|
629
|
+
currentTapAnim = animateTo(targetState, duration);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const onFocus = () => {
|
|
633
|
+
if (!config.focus) return;
|
|
634
|
+
if (currentFocusAnim) currentFocusAnim.cancel();
|
|
635
|
+
currentFocusAnim = animateTo(config.focus, duration);
|
|
636
|
+
};
|
|
637
|
+
const onBlur = () => {
|
|
638
|
+
if (!config.focus) return;
|
|
639
|
+
if (currentFocusAnim) currentFocusAnim.cancel();
|
|
640
|
+
const keys = Object.keys(config.focus);
|
|
641
|
+
const originalStyles: Record<string, string> = {};
|
|
642
|
+
for (const key of keys) {
|
|
643
|
+
originalStyles[key] = el.style[key as any];
|
|
644
|
+
el.style[key as any] = '';
|
|
645
|
+
}
|
|
646
|
+
const baseState = getBaseStyles(keys);
|
|
647
|
+
for (const key of keys) {
|
|
648
|
+
el.style[key as any] = originalStyles[key];
|
|
649
|
+
}
|
|
650
|
+
currentFocusAnim = animateTo(baseState, duration);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
if (config.hover) {
|
|
654
|
+
el.addEventListener('mouseenter', onMouseEnter);
|
|
655
|
+
el.addEventListener('mouseleave', onMouseLeave);
|
|
656
|
+
}
|
|
657
|
+
if (config.tap) {
|
|
658
|
+
el.addEventListener('mousedown', onMouseDown);
|
|
659
|
+
window.addEventListener('mouseup', onMouseUp);
|
|
660
|
+
}
|
|
661
|
+
if (config.focus) {
|
|
662
|
+
el.addEventListener('focus', onFocus);
|
|
663
|
+
el.addEventListener('blur', onBlur);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return () => {
|
|
667
|
+
if (config.hover) {
|
|
668
|
+
el.removeEventListener('mouseenter', onMouseEnter);
|
|
669
|
+
el.removeEventListener('mouseleave', onMouseLeave);
|
|
670
|
+
}
|
|
671
|
+
if (config.tap) {
|
|
672
|
+
el.removeEventListener('mousedown', onMouseDown);
|
|
673
|
+
window.removeEventListener('mouseup', onMouseUp);
|
|
674
|
+
}
|
|
675
|
+
if (config.focus) {
|
|
676
|
+
el.removeEventListener('focus', onFocus);
|
|
677
|
+
el.removeEventListener('blur', onBlur);
|
|
678
|
+
}
|
|
679
|
+
if (currentHoverAnim) currentHoverAnim.cancel();
|
|
680
|
+
if (currentTapAnim) currentTapAnim.cancel();
|
|
681
|
+
if (currentFocusAnim) currentFocusAnim.cancel();
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function animate(config: MotionConfig): (el: HTMLElement) => void;
|
|
687
|
+
export function animate(): {
|
|
688
|
+
fade(): any;
|
|
689
|
+
fly(opts?: { x?: number; y?: number }): any;
|
|
690
|
+
scale(s?: number): any;
|
|
691
|
+
rise(y?: number): any;
|
|
692
|
+
blur(amount?: number): any;
|
|
693
|
+
slide(axis?: 'x' | 'y'): any;
|
|
694
|
+
custom(init: Record<string, any>, anim: Record<string, any>, ex: Record<string, any>): any;
|
|
695
|
+
delay(ms: number): any;
|
|
696
|
+
duration(ms: number): any;
|
|
697
|
+
easing(e: string): any;
|
|
698
|
+
spring(opts?: { stiffness?: number; damping?: number; mass?: number }): any;
|
|
699
|
+
build(): (el: HTMLElement) => void;
|
|
700
|
+
};
|
|
701
|
+
export function animate(config?: MotionConfig): any {
|
|
702
|
+
if (config) {
|
|
703
|
+
return motion(config);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const initial: Record<string, any> = {};
|
|
707
|
+
const animateTarget: Record<string, any> = {};
|
|
708
|
+
const exit: Record<string, any> = {};
|
|
709
|
+
let delay = 0;
|
|
710
|
+
let duration = 300;
|
|
711
|
+
let easing = 'cubic-bezier(0.25, 1, 0.5, 1)';
|
|
712
|
+
const chain = {
|
|
713
|
+
fade() {
|
|
714
|
+
initial.opacity = 0;
|
|
715
|
+
animateTarget.opacity = 1;
|
|
716
|
+
exit.opacity = 0;
|
|
717
|
+
return chain;
|
|
718
|
+
},
|
|
719
|
+
fly(opts: { x?: number; y?: number } = {}) {
|
|
720
|
+
const x = opts.x ?? 0, y = opts.y ?? 0;
|
|
721
|
+
initial.opacity = 0;
|
|
722
|
+
animateTarget.opacity = 1;
|
|
723
|
+
exit.opacity = 0;
|
|
724
|
+
initial.transform = `${initial.transform ?? ''} translate(${x}px, ${y}px)`.trim();
|
|
725
|
+
animateTarget.transform = `${animateTarget.transform ?? ''} translate(0px, 0px)`.trim();
|
|
726
|
+
exit.transform = `${exit.transform ?? ''} translate(${x}px, ${y}px)`.trim();
|
|
727
|
+
return chain;
|
|
728
|
+
},
|
|
729
|
+
scale(s = 0.95) {
|
|
730
|
+
initial.opacity = 0;
|
|
731
|
+
animateTarget.opacity = 1;
|
|
732
|
+
exit.opacity = 0;
|
|
733
|
+
initial.transform = `${initial.transform ?? ''} scale(${s})`.trim();
|
|
734
|
+
animateTarget.transform = `${animateTarget.transform ?? ''} scale(1)`.trim();
|
|
735
|
+
exit.transform = `${exit.transform ?? ''} scale(${s})`.trim();
|
|
736
|
+
return chain;
|
|
737
|
+
},
|
|
738
|
+
rise(y = 20) {
|
|
739
|
+
initial.opacity = 0;
|
|
740
|
+
animateTarget.opacity = 1;
|
|
741
|
+
exit.opacity = 0;
|
|
742
|
+
initial.transform = `${initial.transform ?? ''} translateY(${y}px)`.trim();
|
|
743
|
+
animateTarget.transform = `${animateTarget.transform ?? ''} translateY(0px)`.trim();
|
|
744
|
+
exit.transform = `${exit.transform ?? ''} translateY(${y}px)`.trim();
|
|
745
|
+
return chain;
|
|
746
|
+
},
|
|
747
|
+
blur(amount = 5) {
|
|
748
|
+
initial.filter = `${initial.filter ?? ''} blur(${amount}px)`.trim();
|
|
749
|
+
animateTarget.filter = `${animateTarget.filter ?? ''} blur(0px)`.trim();
|
|
750
|
+
exit.filter = `${exit.filter ?? ''} blur(${amount}px)`.trim();
|
|
751
|
+
return chain;
|
|
752
|
+
},
|
|
753
|
+
slide(axis: 'x' | 'y' = 'y') {
|
|
754
|
+
const prop = axis === 'y' ? 'height' : 'width';
|
|
755
|
+
initial[prop] = '0px';
|
|
756
|
+
initial.overflow = 'hidden';
|
|
757
|
+
initial.opacity = 0;
|
|
758
|
+
|
|
759
|
+
animateTarget[prop] = 'auto';
|
|
760
|
+
animateTarget.overflow = 'hidden';
|
|
761
|
+
animateTarget.opacity = 1;
|
|
762
|
+
|
|
763
|
+
exit[prop] = '0px';
|
|
764
|
+
exit.overflow = 'hidden';
|
|
765
|
+
exit.opacity = 0;
|
|
766
|
+
return chain;
|
|
767
|
+
},
|
|
768
|
+
custom(init: Record<string, any>, anim: Record<string, any>, ex: Record<string, any>) {
|
|
769
|
+
Object.assign(initial, init);
|
|
770
|
+
Object.assign(animateTarget, anim);
|
|
771
|
+
Object.assign(exit, ex);
|
|
772
|
+
return chain;
|
|
773
|
+
},
|
|
774
|
+
delay(ms: number) {
|
|
775
|
+
delay = ms;
|
|
776
|
+
return chain;
|
|
777
|
+
},
|
|
778
|
+
duration(ms: number) {
|
|
779
|
+
duration = ms;
|
|
780
|
+
return chain;
|
|
781
|
+
},
|
|
782
|
+
easing(e: string) {
|
|
783
|
+
easing = e;
|
|
784
|
+
return chain;
|
|
785
|
+
},
|
|
786
|
+
spring(opts: { stiffness?: number; damping?: number; mass?: number } = {}) {
|
|
787
|
+
(chain as any)._spring = {
|
|
788
|
+
type: 'spring',
|
|
789
|
+
stiffness: opts.stiffness ?? 300,
|
|
790
|
+
damping: opts.damping ?? 30,
|
|
791
|
+
mass: opts.mass ?? 1
|
|
792
|
+
};
|
|
793
|
+
return chain;
|
|
794
|
+
},
|
|
795
|
+
build() {
|
|
796
|
+
const springOpts = (chain as any)._spring || {};
|
|
797
|
+
return motion({
|
|
798
|
+
initial,
|
|
799
|
+
animate: animateTarget,
|
|
800
|
+
exit,
|
|
801
|
+
transition: {
|
|
802
|
+
delay,
|
|
803
|
+
duration,
|
|
804
|
+
easing,
|
|
805
|
+
...springOpts
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
return chain;
|
|
812
|
+
}
|