aa2938cb34
Genuinely type composable any usages (useStepper/useStorage/useForm/ createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic any-function and overload-impl signatures with comments; skipped test -> .todo.
367 lines
9.8 KiB
TypeScript
367 lines
9.8 KiB
TypeScript
import { computed, ref, toValue, watch } from 'vue';
|
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
|
import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
|
|
import { defaultWindow } from '@/types';
|
|
import type { ConfigurableWindow } from '@/types';
|
|
import { useRafFn } from '@/composables/animation/useRafFn';
|
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
|
|
|
/**
|
|
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
|
|
* `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order.
|
|
*/
|
|
export type CubicBezierPoints = [number, number, number, number];
|
|
|
|
/**
|
|
* An easing function mapping linear progress in `[0, 1]` to eased progress.
|
|
*/
|
|
export type EasingFunction = (n: number) => number;
|
|
|
|
/**
|
|
* Interpolates between two values of `T` given an eased progress `alpha`.
|
|
*/
|
|
export type TransitionInterpolation<T> = (from: T, to: T, alpha: number) => T;
|
|
|
|
/**
|
|
* The transition easing: either a cubic bezier tuple or a custom easing function.
|
|
*/
|
|
export type TransitionEasing = CubicBezierPoints | EasingFunction;
|
|
|
|
/**
|
|
* Values that can be transitioned: a single number or a fixed-length number array.
|
|
*/
|
|
export type TransitionValue = number | number[];
|
|
|
|
/**
|
|
* Common cubic bezier easing presets (same curves as CSS / VueUse).
|
|
*/
|
|
export const TransitionPresets = {
|
|
linear: [0, 0, 1, 1],
|
|
easeInSine: [0.12, 0, 0.39, 0],
|
|
easeOutSine: [0.61, 1, 0.88, 1],
|
|
easeInOutSine: [0.37, 0, 0.63, 1],
|
|
easeInQuad: [0.11, 0, 0.5, 0],
|
|
easeOutQuad: [0.5, 1, 0.89, 1],
|
|
easeInOutQuad: [0.45, 0, 0.55, 1],
|
|
easeInCubic: [0.32, 0, 0.67, 0],
|
|
easeOutCubic: [0.33, 1, 0.68, 1],
|
|
easeInOutCubic: [0.65, 0, 0.35, 1],
|
|
easeInQuart: [0.5, 0, 0.75, 0],
|
|
easeOutQuart: [0.25, 1, 0.5, 1],
|
|
easeInOutQuart: [0.76, 0, 0.24, 1],
|
|
easeInQuint: [0.64, 0, 0.78, 0],
|
|
easeOutQuint: [0.22, 1, 0.36, 1],
|
|
easeInOutQuint: [0.83, 0, 0.17, 1],
|
|
easeInExpo: [0.7, 0, 0.84, 0],
|
|
easeOutExpo: [0.16, 1, 0.3, 1],
|
|
easeInOutExpo: [0.87, 0, 0.13, 1],
|
|
easeInCirc: [0.55, 0, 1, 0.45],
|
|
easeOutCirc: [0, 0.55, 0.45, 1],
|
|
easeInOutCirc: [0.85, 0, 0.15, 1],
|
|
easeInBack: [0.36, 0, 0.66, -0.56],
|
|
easeOutBack: [0.34, 1.56, 0.64, 1],
|
|
easeInOutBack: [0.68, -0.6, 0.32, 1.6],
|
|
} satisfies Record<string, CubicBezierPoints>;
|
|
|
|
export interface UseTransitionOptions {
|
|
/**
|
|
* Transition duration in milliseconds. Accepts a reactive value (resolved
|
|
* at the start of each transition).
|
|
*
|
|
* @default 1000
|
|
*/
|
|
duration?: MaybeRefOrGetter<number>;
|
|
|
|
/**
|
|
* Easing applied to the progress: a cubic bezier tuple (e.g. one of
|
|
* {@link TransitionPresets}) or a custom easing function.
|
|
*
|
|
* @default identity (linear)
|
|
*/
|
|
transition?: MaybeRefOrGetter<TransitionEasing>;
|
|
|
|
/**
|
|
* Delay in milliseconds before a transition begins after the source changes.
|
|
*
|
|
* @default 0
|
|
*/
|
|
delay?: MaybeRefOrGetter<number>;
|
|
|
|
/**
|
|
* When `true`, transitions are skipped and the output tracks the source
|
|
* value directly (no animation). Reactive.
|
|
*
|
|
* @default false
|
|
*/
|
|
disabled?: MaybeRefOrGetter<boolean>;
|
|
|
|
/**
|
|
* Called when a transition starts.
|
|
*/
|
|
onStarted?: () => void;
|
|
|
|
/**
|
|
* Called when a transition finishes (not called when aborted by a new change).
|
|
*/
|
|
onFinished?: () => void;
|
|
}
|
|
|
|
export type UseTransitionReturn<T> = Readonly<Ref<T>>;
|
|
|
|
const identity: EasingFunction = n => n;
|
|
|
|
interface BezierCoefficients {
|
|
a: (a1: number, a2: number) => number;
|
|
b: (a1: number, a2: number) => number;
|
|
c: (a1: number) => number;
|
|
}
|
|
|
|
function createEasingFunction(points: CubicBezierPoints): EasingFunction {
|
|
const [p0, p1, p2, p3] = points;
|
|
|
|
const coeffs: BezierCoefficients = {
|
|
a: (a1, a2) => 1 - 3 * a2 + 3 * a1,
|
|
b: (a1, a2) => 3 * a2 - 6 * a1,
|
|
c: a1 => 3 * a1,
|
|
};
|
|
|
|
const calcBezier = (t: number, a1: number, a2: number): number =>
|
|
((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t;
|
|
|
|
const getSlope = (t: number, a1: number, a2: number): number =>
|
|
3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1);
|
|
|
|
const getTForX = (x: number): number => {
|
|
let guess = x;
|
|
|
|
for (let i = 0; i < 4; ++i) {
|
|
const slope = getSlope(guess, p0, p2);
|
|
|
|
if (slope === 0)
|
|
return guess;
|
|
|
|
const currentX = calcBezier(guess, p0, p2) - x;
|
|
guess -= currentX / slope;
|
|
}
|
|
|
|
return guess;
|
|
};
|
|
|
|
return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3);
|
|
}
|
|
|
|
function resolveEasing(transition: TransitionEasing | undefined): EasingFunction {
|
|
if (!transition)
|
|
return identity;
|
|
|
|
if (isFunction(transition))
|
|
return transition;
|
|
|
|
return createEasingFunction(transition);
|
|
}
|
|
|
|
// Interpolate a single number or a (fixed-length) numeric array.
|
|
function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue {
|
|
if (isNumber(from) && isNumber(to))
|
|
return lerp(from, to, alpha);
|
|
|
|
const source = from as number[];
|
|
const target = to as number[];
|
|
|
|
return source.map((value, index) => lerp(value, target[index] ?? value, alpha));
|
|
}
|
|
|
|
function snapshot<T extends TransitionValue>(value: T): T {
|
|
return (isNumber(value) ? value : (value as number[]).slice()) as T;
|
|
}
|
|
|
|
function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
|
|
if (isNumber(a) && isNumber(b))
|
|
return a === b;
|
|
|
|
if (isNumber(a) || isNumber(b))
|
|
return false;
|
|
|
|
if (a.length !== b.length)
|
|
return false;
|
|
|
|
for (let i = 0; i < a.length; ++i) {
|
|
if (a[i] !== b[i])
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @name useTransition
|
|
* @category Animation
|
|
* @description Reactively transition between numeric values (or numeric arrays)
|
|
* over a duration with configurable easing. Wraps a single, paused
|
|
* `requestAnimationFrame` loop that only runs while a transition is in flight,
|
|
* so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a
|
|
* `window` the output tracks the source synchronously.
|
|
*
|
|
* @param {MaybeRefOrGetter<T>} source The reactive source value (number or number[])
|
|
* @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options
|
|
* @returns {UseTransitionReturn<T>} A readonly ref of the transitioned value
|
|
*
|
|
* @example
|
|
* const progress = ref(0);
|
|
* const output = useTransition(progress, {
|
|
* duration: 500,
|
|
* transition: TransitionPresets.easeOutCubic,
|
|
* });
|
|
*
|
|
* @example
|
|
* // Transition a tuple (e.g. an RGB color)
|
|
* const color = ref([0, 0, 0]);
|
|
* const animated = useTransition(color, { duration: 1000 });
|
|
*
|
|
* @since 0.0.15
|
|
*/
|
|
export function useTransition<T extends TransitionValue>(
|
|
source: MaybeRefOrGetter<T>,
|
|
options: UseTransitionOptions & ConfigurableWindow = {},
|
|
): UseTransitionReturn<T> {
|
|
const {
|
|
duration = 1000,
|
|
transition = identity,
|
|
delay = 0,
|
|
disabled = false,
|
|
onStarted = noop,
|
|
onFinished = noop,
|
|
} = options;
|
|
|
|
const window = 'window' in options ? options.window : defaultWindow;
|
|
|
|
// The animated output. Seeded with a snapshot of the current source.
|
|
const outputRef = ref(snapshot(toValue(source))) as Ref<T>;
|
|
|
|
// Active-transition state. `endpoints` are detached snapshots so that later
|
|
// source mutations cannot bleed into an in-flight transition.
|
|
let fromValue: T = outputRef.value;
|
|
let toValue_: T = outputRef.value;
|
|
let startedAt = 0;
|
|
let durationMs = 0;
|
|
let easing: EasingFunction = identity;
|
|
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let finishPending = false;
|
|
|
|
const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window });
|
|
|
|
function clearDelay() {
|
|
if (delayTimer !== null && window) {
|
|
window.clearTimeout(delayTimer);
|
|
delayTimer = null;
|
|
}
|
|
}
|
|
|
|
function settle(value: T) {
|
|
outputRef.value = snapshot(value);
|
|
|
|
if (isActive.value)
|
|
pause();
|
|
|
|
if (finishPending) {
|
|
finishPending = false;
|
|
onFinished();
|
|
}
|
|
}
|
|
|
|
function tick() {
|
|
const now = Date.now();
|
|
const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1);
|
|
|
|
outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T;
|
|
|
|
if (alpha >= 1)
|
|
settle(toValue_);
|
|
}
|
|
|
|
function begin(target: T) {
|
|
fromValue = snapshot(outputRef.value);
|
|
toValue_ = snapshot(target);
|
|
|
|
durationMs = Math.max(0, toValue(duration));
|
|
easing = resolveEasing(toValue(transition));
|
|
startedAt = Date.now();
|
|
finishPending = true;
|
|
|
|
onStarted();
|
|
|
|
if (durationMs <= 0 || !window) {
|
|
settle(toValue_);
|
|
return;
|
|
}
|
|
|
|
if (!isActive.value)
|
|
resume();
|
|
}
|
|
|
|
function start(target: T) {
|
|
clearDelay();
|
|
|
|
const delayMs = Math.max(0, toValue(delay));
|
|
|
|
if (delayMs > 0 && window) {
|
|
delayTimer = window.setTimeout(() => {
|
|
delayTimer = null;
|
|
begin(target);
|
|
}, delayMs);
|
|
|
|
return;
|
|
}
|
|
|
|
begin(target);
|
|
}
|
|
|
|
watch(
|
|
() => toValue(source),
|
|
(value) => {
|
|
// When disabled, mirror the source instantly and abort any animation.
|
|
if (toValue(disabled)) {
|
|
clearDelay();
|
|
|
|
if (isActive.value)
|
|
pause();
|
|
|
|
finishPending = false;
|
|
outputRef.value = snapshot(value);
|
|
|
|
return;
|
|
}
|
|
|
|
// Skip no-op changes so we don't restart an identical transition.
|
|
if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null)
|
|
return;
|
|
|
|
start(value);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
// Reacting to `disabled` flipping to true mid-transition: snap to source.
|
|
watch(
|
|
() => toValue(disabled),
|
|
(off) => {
|
|
if (off) {
|
|
clearDelay();
|
|
|
|
if (isActive.value)
|
|
pause();
|
|
|
|
finishPending = false;
|
|
outputRef.value = snapshot(toValue(source));
|
|
}
|
|
},
|
|
);
|
|
|
|
// The RAF loop is torn down by useRafFn on scope dispose, but a pending start
|
|
// delay (window.setTimeout) is not — clear it so the timer can't fire into a
|
|
// disposed scope.
|
|
tryOnScopeDispose(clearDelay);
|
|
|
|
return computed(() => outputRef.value);
|
|
}
|