what-core 0.1.1 → 0.2.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.
@@ -0,0 +1,531 @@
1
+ // What Framework - Animation Primitives
2
+ // Springs, tweens, gestures, and transition helpers
3
+
4
+ import { signal, effect, untrack, batch } from './reactive.js';
5
+ import { scheduleRead, scheduleWrite } from './scheduler.js';
6
+
7
+ // --- Spring Animation ---
8
+ // Physics-based animation with natural feel
9
+
10
+ export function spring(initialValue, options = {}) {
11
+ const {
12
+ stiffness = 100,
13
+ damping = 10,
14
+ mass = 1,
15
+ precision = 0.01,
16
+ } = options;
17
+
18
+ const current = signal(initialValue);
19
+ const target = signal(initialValue);
20
+ const velocity = signal(0);
21
+ const isAnimating = signal(false);
22
+
23
+ let rafId = null;
24
+ let lastTime = null;
25
+
26
+ function tick(time) {
27
+ if (lastTime === null) {
28
+ lastTime = time;
29
+ rafId = requestAnimationFrame(tick);
30
+ return;
31
+ }
32
+
33
+ const dt = Math.min((time - lastTime) / 1000, 0.064); // Cap at ~15fps minimum
34
+ lastTime = time;
35
+
36
+ const currentVal = current.peek();
37
+ const targetVal = target.peek();
38
+ const vel = velocity.peek();
39
+
40
+ // Spring physics
41
+ const displacement = currentVal - targetVal;
42
+ const springForce = -stiffness * displacement;
43
+ const dampingForce = -damping * vel;
44
+ const acceleration = (springForce + dampingForce) / mass;
45
+
46
+ const newVelocity = vel + acceleration * dt;
47
+ const newValue = currentVal + newVelocity * dt;
48
+
49
+ batch(() => {
50
+ velocity.set(newVelocity);
51
+ current.set(newValue);
52
+ });
53
+
54
+ // Check if settled
55
+ if (Math.abs(newVelocity) < precision && Math.abs(displacement) < precision) {
56
+ batch(() => {
57
+ current.set(targetVal);
58
+ velocity.set(0);
59
+ isAnimating.set(false);
60
+ });
61
+ rafId = null;
62
+ lastTime = null;
63
+ return;
64
+ }
65
+
66
+ rafId = requestAnimationFrame(tick);
67
+ }
68
+
69
+ function set(newTarget) {
70
+ target.set(newTarget);
71
+ if (!isAnimating.peek()) {
72
+ isAnimating.set(true);
73
+ lastTime = null;
74
+ rafId = requestAnimationFrame(tick);
75
+ }
76
+ }
77
+
78
+ function stop() {
79
+ if (rafId) {
80
+ cancelAnimationFrame(rafId);
81
+ rafId = null;
82
+ }
83
+ isAnimating.set(false);
84
+ lastTime = null;
85
+ }
86
+
87
+ function snap(value) {
88
+ stop();
89
+ batch(() => {
90
+ current.set(value);
91
+ target.set(value);
92
+ velocity.set(0);
93
+ });
94
+ }
95
+
96
+ return {
97
+ current: () => current(),
98
+ target: () => target(),
99
+ velocity: () => velocity(),
100
+ isAnimating: () => isAnimating(),
101
+ set,
102
+ stop,
103
+ snap,
104
+ subscribe: current.subscribe,
105
+ };
106
+ }
107
+
108
+ // --- Tween Animation ---
109
+ // Easing-based animation
110
+
111
+ export function tween(from, to, options = {}) {
112
+ const {
113
+ duration = 300,
114
+ easing = (t) => t * (2 - t), // easeOutQuad
115
+ onUpdate,
116
+ onComplete,
117
+ } = options;
118
+
119
+ const progress = signal(0);
120
+ const value = signal(from);
121
+ const isAnimating = signal(true);
122
+
123
+ let startTime = null;
124
+ let rafId = null;
125
+
126
+ function tick(time) {
127
+ if (startTime === null) startTime = time;
128
+
129
+ const elapsed = time - startTime;
130
+ const t = Math.min(elapsed / duration, 1);
131
+ const easedT = easing(t);
132
+ const currentValue = from + (to - from) * easedT;
133
+
134
+ batch(() => {
135
+ progress.set(t);
136
+ value.set(currentValue);
137
+ });
138
+
139
+ if (onUpdate) onUpdate(currentValue, t);
140
+
141
+ if (t < 1) {
142
+ rafId = requestAnimationFrame(tick);
143
+ } else {
144
+ isAnimating.set(false);
145
+ if (onComplete) onComplete();
146
+ }
147
+ }
148
+
149
+ rafId = requestAnimationFrame(tick);
150
+
151
+ return {
152
+ progress: () => progress(),
153
+ value: () => value(),
154
+ isAnimating: () => isAnimating(),
155
+ cancel: () => {
156
+ if (rafId) cancelAnimationFrame(rafId);
157
+ isAnimating.set(false);
158
+ },
159
+ subscribe: value.subscribe,
160
+ };
161
+ }
162
+
163
+ // --- Easing Functions ---
164
+
165
+ export const easings = {
166
+ linear: (t) => t,
167
+ easeInQuad: (t) => t * t,
168
+ easeOutQuad: (t) => t * (2 - t),
169
+ easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
170
+ easeInCubic: (t) => t * t * t,
171
+ easeOutCubic: (t) => (--t) * t * t + 1,
172
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
173
+ easeInElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3)),
174
+ easeOutElastic: (t) => t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,
175
+ easeOutBounce: (t) => {
176
+ const n1 = 7.5625;
177
+ const d1 = 2.75;
178
+ if (t < 1 / d1) return n1 * t * t;
179
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
180
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
181
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
182
+ },
183
+ };
184
+
185
+ // --- useTransition Hook ---
186
+ // Animate between states
187
+
188
+ export function useTransition(options = {}) {
189
+ const { duration = 300, easing = easings.easeOutQuad } = options;
190
+
191
+ const isTransitioning = signal(false);
192
+ const progress = signal(0);
193
+
194
+ async function start(callback) {
195
+ isTransitioning.set(true);
196
+ progress.set(0);
197
+
198
+ return new Promise((resolve) => {
199
+ const startTime = performance.now();
200
+
201
+ function tick(time) {
202
+ const elapsed = time - startTime;
203
+ const t = Math.min(elapsed / duration, 1);
204
+ progress.set(easing(t));
205
+
206
+ if (t < 1) {
207
+ requestAnimationFrame(tick);
208
+ } else {
209
+ isTransitioning.set(false);
210
+ if (callback) callback();
211
+ resolve();
212
+ }
213
+ }
214
+
215
+ requestAnimationFrame(tick);
216
+ });
217
+ }
218
+
219
+ return {
220
+ isTransitioning: () => isTransitioning(),
221
+ progress: () => progress(),
222
+ start,
223
+ };
224
+ }
225
+
226
+ // --- Gesture Handlers ---
227
+
228
+ export function useGesture(element, handlers = {}) {
229
+ const {
230
+ onDrag,
231
+ onDragStart,
232
+ onDragEnd,
233
+ onPinch,
234
+ onSwipe,
235
+ onTap,
236
+ onLongPress,
237
+ } = handlers;
238
+
239
+ const state = {
240
+ isDragging: signal(false),
241
+ startX: 0,
242
+ startY: 0,
243
+ currentX: signal(0),
244
+ currentY: signal(0),
245
+ deltaX: signal(0),
246
+ deltaY: signal(0),
247
+ velocity: signal({ x: 0, y: 0 }),
248
+ };
249
+
250
+ let lastTime = 0;
251
+ let lastX = 0;
252
+ let lastY = 0;
253
+ let longPressTimer = null;
254
+
255
+ function handleStart(e) {
256
+ const touch = e.touches ? e.touches[0] : e;
257
+ state.startX = touch.clientX;
258
+ state.startY = touch.clientY;
259
+ lastX = touch.clientX;
260
+ lastY = touch.clientY;
261
+ lastTime = performance.now();
262
+
263
+ state.isDragging.set(true);
264
+ if (onDragStart) onDragStart({ x: state.startX, y: state.startY });
265
+
266
+ // Long press detection
267
+ if (onLongPress) {
268
+ longPressTimer = setTimeout(() => {
269
+ if (state.isDragging.peek()) {
270
+ onLongPress({ x: lastX, y: lastY });
271
+ }
272
+ }, 500);
273
+ }
274
+ }
275
+
276
+ function handleMove(e) {
277
+ if (!state.isDragging.peek()) return;
278
+
279
+ const touch = e.touches ? e.touches[0] : e;
280
+ const x = touch.clientX;
281
+ const y = touch.clientY;
282
+ const now = performance.now();
283
+ const dt = now - lastTime;
284
+
285
+ batch(() => {
286
+ state.currentX.set(x);
287
+ state.currentY.set(y);
288
+ state.deltaX.set(x - state.startX);
289
+ state.deltaY.set(y - state.startY);
290
+
291
+ if (dt > 0) {
292
+ state.velocity.set({
293
+ x: (x - lastX) / dt * 1000,
294
+ y: (y - lastY) / dt * 1000,
295
+ });
296
+ }
297
+ });
298
+
299
+ lastX = x;
300
+ lastY = y;
301
+ lastTime = now;
302
+
303
+ if (longPressTimer) {
304
+ // Cancel long press if moved too much
305
+ const distance = Math.sqrt(state.deltaX.peek() ** 2 + state.deltaY.peek() ** 2);
306
+ if (distance > 10) {
307
+ clearTimeout(longPressTimer);
308
+ longPressTimer = null;
309
+ }
310
+ }
311
+
312
+ if (onDrag) {
313
+ onDrag({
314
+ x,
315
+ y,
316
+ deltaX: state.deltaX.peek(),
317
+ deltaY: state.deltaY.peek(),
318
+ velocity: state.velocity.peek(),
319
+ });
320
+ }
321
+ }
322
+
323
+ function handleEnd(e) {
324
+ if (!state.isDragging.peek()) return;
325
+
326
+ if (longPressTimer) {
327
+ clearTimeout(longPressTimer);
328
+ longPressTimer = null;
329
+ }
330
+
331
+ const deltaX = state.deltaX.peek();
332
+ const deltaY = state.deltaY.peek();
333
+ const velocity = state.velocity.peek();
334
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
335
+
336
+ // Tap detection
337
+ if (distance < 10 && onTap) {
338
+ onTap({ x: state.startX, y: state.startY });
339
+ }
340
+
341
+ // Swipe detection
342
+ if (onSwipe && (Math.abs(velocity.x) > 500 || Math.abs(velocity.y) > 500)) {
343
+ const direction = Math.abs(velocity.x) > Math.abs(velocity.y)
344
+ ? (velocity.x > 0 ? 'right' : 'left')
345
+ : (velocity.y > 0 ? 'down' : 'up');
346
+ onSwipe({ direction, velocity });
347
+ }
348
+
349
+ if (onDragEnd) {
350
+ onDragEnd({
351
+ deltaX,
352
+ deltaY,
353
+ velocity,
354
+ });
355
+ }
356
+
357
+ state.isDragging.set(false);
358
+ }
359
+
360
+ // Pinch handling (touch only)
361
+ let initialPinchDistance = null;
362
+
363
+ function handlePinchMove(e) {
364
+ if (!onPinch || e.touches.length !== 2) return;
365
+
366
+ const touch1 = e.touches[0];
367
+ const touch2 = e.touches[1];
368
+ const distance = Math.sqrt(
369
+ (touch2.clientX - touch1.clientX) ** 2 +
370
+ (touch2.clientY - touch1.clientY) ** 2
371
+ );
372
+
373
+ if (initialPinchDistance === null) {
374
+ initialPinchDistance = distance;
375
+ }
376
+
377
+ const scale = distance / initialPinchDistance;
378
+ const centerX = (touch1.clientX + touch2.clientX) / 2;
379
+ const centerY = (touch1.clientY + touch2.clientY) / 2;
380
+
381
+ onPinch({ scale, centerX, centerY });
382
+ }
383
+
384
+ function handlePinchEnd() {
385
+ initialPinchDistance = null;
386
+ }
387
+
388
+ // Attach listeners
389
+ if (typeof element === 'function') {
390
+ // Ref function
391
+ effect(() => {
392
+ const el = untrack(element);
393
+ if (!el) return;
394
+ return attachListeners(el);
395
+ });
396
+ } else if (element?.current !== undefined) {
397
+ // Ref object
398
+ effect(() => {
399
+ const el = element.current;
400
+ if (!el) return;
401
+ return attachListeners(el);
402
+ });
403
+ } else if (element) {
404
+ attachListeners(element);
405
+ }
406
+
407
+ function attachListeners(el) {
408
+ el.addEventListener('mousedown', handleStart);
409
+ el.addEventListener('touchstart', handleStart, { passive: true });
410
+ window.addEventListener('mousemove', handleMove);
411
+ window.addEventListener('touchmove', handlePinchMove);
412
+ window.addEventListener('touchmove', handleMove);
413
+ window.addEventListener('mouseup', handleEnd);
414
+ window.addEventListener('touchend', handleEnd);
415
+ window.addEventListener('touchend', handlePinchEnd);
416
+
417
+ return () => {
418
+ el.removeEventListener('mousedown', handleStart);
419
+ el.removeEventListener('touchstart', handleStart);
420
+ window.removeEventListener('mousemove', handleMove);
421
+ window.removeEventListener('touchmove', handlePinchMove);
422
+ window.removeEventListener('touchmove', handleMove);
423
+ window.removeEventListener('mouseup', handleEnd);
424
+ window.removeEventListener('touchend', handleEnd);
425
+ window.removeEventListener('touchend', handlePinchEnd);
426
+ };
427
+ }
428
+
429
+ return state;
430
+ }
431
+
432
+ // --- useAnimatedValue Hook ---
433
+ // Like React Native's Animated.Value
434
+
435
+ export function useAnimatedValue(initialValue) {
436
+ const value = signal(initialValue);
437
+ const animations = [];
438
+
439
+ return {
440
+ value: () => value(),
441
+ setValue: (v) => value.set(v),
442
+
443
+ // Spring to target
444
+ spring(toValue, config = {}) {
445
+ const s = spring(value.peek(), config);
446
+ s.set(toValue);
447
+
448
+ const dispose = effect(() => {
449
+ value.set(s.current());
450
+ });
451
+
452
+ return {
453
+ stop: () => { s.stop(); dispose(); },
454
+ };
455
+ },
456
+
457
+ // Tween to target
458
+ timing(toValue, config = {}) {
459
+ const t = tween(value.peek(), toValue, {
460
+ ...config,
461
+ onUpdate: (v) => value.set(v),
462
+ });
463
+
464
+ return {
465
+ stop: () => t.cancel(),
466
+ };
467
+ },
468
+
469
+ // Interpolate value
470
+ interpolate(inputRange, outputRange) {
471
+ return () => {
472
+ const v = value();
473
+ // Find segment
474
+ for (let i = 0; i < inputRange.length - 1; i++) {
475
+ if (v >= inputRange[i] && v <= inputRange[i + 1]) {
476
+ const t = (v - inputRange[i]) / (inputRange[i + 1] - inputRange[i]);
477
+ return outputRange[i] + (outputRange[i + 1] - outputRange[i]) * t;
478
+ }
479
+ }
480
+ // Clamp
481
+ if (v <= inputRange[0]) return outputRange[0];
482
+ return outputRange[outputRange.length - 1];
483
+ };
484
+ },
485
+
486
+ subscribe: value.subscribe,
487
+ };
488
+ }
489
+
490
+ // --- CSS Transition Classes ---
491
+
492
+ export function createTransitionClasses(name) {
493
+ return {
494
+ enter: `${name}-enter`,
495
+ enterActive: `${name}-enter-active`,
496
+ enterDone: `${name}-enter-done`,
497
+ exit: `${name}-exit`,
498
+ exitActive: `${name}-exit-active`,
499
+ exitDone: `${name}-exit-done`,
500
+ };
501
+ }
502
+
503
+ // Apply CSS transition
504
+ export async function cssTransition(element, name, type = 'enter', duration = 300) {
505
+ const classes = createTransitionClasses(name);
506
+
507
+ return new Promise((resolve) => {
508
+ scheduleWrite(() => {
509
+ // Initial state
510
+ element.classList.add(classes[type]);
511
+
512
+ // Force reflow
513
+ scheduleRead(() => {
514
+ element.offsetHeight;
515
+
516
+ scheduleWrite(() => {
517
+ // Active state
518
+ element.classList.add(classes[`${type}Active`]);
519
+
520
+ setTimeout(() => {
521
+ scheduleWrite(() => {
522
+ element.classList.remove(classes[type], classes[`${type}Active`]);
523
+ element.classList.add(classes[`${type}Done`]);
524
+ resolve();
525
+ });
526
+ }, duration);
527
+ });
528
+ });
529
+ });
530
+ });
531
+ }