import { computed, shallowReactive, shallowRef, toValue, watch } from 'vue'; import type { ComputedRef, MaybeRef, Ref, ShallowRef, WritableComputedRef } from 'vue'; import { isObject, noop, omit } from '@robonen/stdlib'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; import { unrefElement } from '@/composables/component/unrefElement'; import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; import { useRafFn } from '@/composables/animation/useRafFn'; import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; export interface UseAnimateOptions extends KeyframeAnimationOptions, ConfigurableWindow { /** * Automatically call `play()` once the target element is resolved. * * @default true */ immediate?: boolean; /** * Commit the end styling state of the animation to the element when it finishes. * Usually paired with the `fill` option. * * @default false */ commitStyles?: boolean; /** * Persist the animation so it is not automatically removed by the browser. * * @default false */ persist?: boolean; /** * Called once the underlying `Animation` instance has been created. */ onReady?: (animation: Animation) => void; /** * Called when an error is thrown while controlling the animation. * * @default noop */ onError?: (error: unknown) => void; } export type UseAnimateKeyframes = MaybeRef; export interface UseAnimateReturn { /** * Whether the Web Animations API is supported in the current environment */ isSupported: Readonly>; /** * The underlying `Animation` instance, or `undefined` before it is created */ animate: ShallowRef; /** * Start or resume the animation */ play: () => void; /** * Suspend playback of the animation */ pause: () => void; /** * Reverse the playback direction of the animation */ reverse: () => void; /** * Seek the animation to the end of its active duration */ finish: () => void; /** * Abort the animation, clearing its effects */ cancel: () => void; /** * Whether the animation is currently waiting for an asynchronous operation */ pending: ComputedRef; /** * The current playback state of the animation */ playState: ComputedRef; /** * The current replace state of the animation */ replaceState: ComputedRef; /** * The scheduled time at which the animation should begin (writable) */ startTime: WritableComputedRef; /** * The current time value of the animation in milliseconds (writable) */ currentTime: WritableComputedRef; /** * The timeline associated with the animation (writable) */ timeline: WritableComputedRef; /** * The playback rate of the animation (writable) */ playbackRate: WritableComputedRef; } interface AnimateStore { startTime: CSSNumberish | number | null; currentTime: CSSNumberish | null; timeline: AnimationTimeline | null; playbackRate: number; pending: boolean; playState: AnimationPlayState; replaceState: AnimationReplaceState; } const RESERVED_KEYS = [ 'window', 'immediate', 'commitStyles', 'persist', 'onReady', 'onError', ] as const; /** * @name useAnimate * @category Animation * @description Reactive [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) * wrapper for a single element. Exposes imperative controls (`play`, `pause`, `reverse`, * `finish`, `cancel`) alongside reactive state (`playState`, `currentTime`, `playbackRate`, ...). * The reactive state is synced via `requestAnimationFrame` only while the animation is running, * so an idle animation costs nothing. SSR-safe: nothing touches the DOM until the element resolves. * * @param {MaybeComputedElementRef} target Element to animate (reactive ref, getter, or element) * @param {UseAnimateKeyframes} keyframes Keyframes to animate, reactive * @param {number | UseAnimateOptions} [options] Duration in ms, or full options object * @returns {UseAnimateReturn} Support flag, the `Animation` instance, controls, and reactive state * * @example * const el = useTemplateRef('el'); * const { playState, play, pause } = useAnimate( * el, * [{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }], * { duration: 1000, iterations: Infinity }, * ); * * @example * // Shorthand: third argument is the duration in milliseconds * useAnimate(el, { opacity: [0, 1] }, 500); * * @since 0.0.14 */ export function useAnimate( target: MaybeComputedElementRef, keyframes: UseAnimateKeyframes, options?: number | UseAnimateOptions, ): UseAnimateReturn { let config: UseAnimateOptions; let animateOptions: number | KeyframeAnimationOptions | undefined; if (isObject(options)) { config = options; animateOptions = omit(options, RESERVED_KEYS as unknown as Array); } else { config = { duration: options }; animateOptions = options; } const { immediate = true, commitStyles = false, persist = false, playbackRate: initialPlaybackRate = 1, onReady, onError = noop, } = config; // Honor an explicit `window: undefined` (SSR / opt-out) rather than letting a // default parameter silently restore `defaultWindow`. const window = 'window' in config ? config.window : defaultWindow; const isSupported = useSupported(() => Boolean(window) && typeof HTMLElement !== 'undefined' && 'animate' in HTMLElement.prototype); const animate = shallowRef(undefined); const store = shallowReactive({ startTime: null, currentTime: null, timeline: null, playbackRate: initialPlaybackRate, pending: false, playState: immediate ? 'idle' : 'paused', replaceState: 'active', }); const pending = computed(() => store.pending); const playState = computed(() => store.playState); const replaceState = computed(() => store.replaceState); const startTime = computed({ get: () => store.startTime, set(value) { store.startTime = value; if (animate.value) animate.value.startTime = value; }, }); const currentTime = computed({ get: () => store.currentTime, set(value) { store.currentTime = value; if (animate.value) { animate.value.currentTime = value; syncResume(); } }, }); const timeline = computed({ get: () => store.timeline, set(value) { store.timeline = value; if (animate.value) animate.value.timeline = value; }, }); const playbackRate = computed({ get: () => store.playbackRate, set(value) { store.playbackRate = value; if (animate.value) animate.value.playbackRate = value; }, }); function update(init?: boolean): void { const el = unrefElement(target); if (!isSupported.value || !el) return; if (!animate.value) animate.value = (el as HTMLElement).animate(toValue(keyframes), animateOptions); if (persist) animate.value.persist(); if (initialPlaybackRate !== 1) animate.value.playbackRate = initialPlaybackRate; if (init && !immediate) animate.value.pause(); else syncResume(); onReady?.(animate.value); } function play(): void { if (!animate.value) { update(); return; } try { animate.value.play(); syncResume(); } catch (error) { syncPause(); onError(error); } } function pause(): void { try { animate.value?.pause(); syncPause(); } catch (error) { onError(error); } } function reverse(): void { if (!animate.value) update(); try { animate.value?.reverse(); syncResume(); } catch (error) { syncPause(); onError(error); } } function finish(): void { try { animate.value?.finish(); syncPause(); } catch (error) { onError(error); } } function cancel(): void { try { animate.value?.cancel(); syncPause(); } catch (error) { onError(error); } } // Sync the reactive store from the live Animation on every frame. The loop is // paused by default and only resumed while the animation is actually playing, // so an idle (or finished) animation incurs zero per-frame cost. const { resume: resumeRaf, pause: pauseRaf } = useRafFn(() => { const a = animate.value; if (!a) return; store.pending = a.pending; store.playState = a.playState; store.replaceState = a.replaceState; store.startTime = a.startTime; store.currentTime = a.currentTime; store.timeline = a.timeline; store.playbackRate = a.playbackRate; }, { immediate: false, window }); function syncResume(): void { if (isSupported.value) resumeRaf(); } function syncPause(): void { // Defer the stop by one frame so the final state (e.g. 'finished') is captured // before the loop halts. if (isSupported.value && window) window.requestAnimationFrame(pauseRaf); } watch(() => unrefElement(target), (el) => { if (el) update(true); else animate.value = undefined; }); watch(() => keyframes, (value) => { if (!animate.value) return; update(); const el = unrefElement(target); if (el && typeof KeyframeEffect !== 'undefined') animate.value.effect = new KeyframeEffect(el as HTMLElement, toValue(value), animateOptions); }, { deep: true }); const listenerOptions = { passive: true }; useEventListener(animate, ['cancel', 'finish', 'remove'], syncPause, listenerOptions); useEventListener(animate, 'finish', () => { if (commitStyles) animate.value?.commitStyles(); }, listenerOptions); tryOnMounted(() => update(true), { sync: false }); tryOnScopeDispose(cancel); return { isSupported, animate, play, pause, reverse, finish, cancel, pending, playState, replaceState, startTime, currentTime, timeline, playbackRate, }; }